statgen 0.2.3__tar.gz → 0.2.4__tar.gz
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.
- {statgen-0.2.3 → statgen-0.2.4}/PKG-INFO +1 -1
- {statgen-0.2.3 → statgen-0.2.4}/pyproject.toml +1 -1
- statgen-0.2.4/statgen/__init__.py +1 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_bfile_utils.py +9 -5
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_variant_match.py +21 -3
- {statgen-0.2.3 → statgen-0.2.4}/statgen/genotype.py +61 -5
- {statgen-0.2.3 → statgen-0.2.4}/statgen/reference.py +56 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/sumstats.py +16 -4
- {statgen-0.2.3 → statgen-0.2.4}/statgen.egg-info/PKG-INFO +1 -1
- statgen-0.2.3/statgen/__init__.py +0 -1
- {statgen-0.2.3 → statgen-0.2.4}/README.md +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/setup.cfg +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_ld_npz.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_ld_reference.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_ld_schema.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_ld_writer.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/_utils.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/annotations.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen/ld.py +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen.egg-info/SOURCES.txt +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen.egg-info/dependency_links.txt +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen.egg-info/requires.txt +0 -0
- {statgen-0.2.3 → statgen-0.2.4}/statgen.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.4"
|
|
@@ -180,12 +180,16 @@ def _parse_fam(path: Path) -> pd.DataFrame:
|
|
|
180
180
|
return out.reset_index(drop=True)
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def _parse_ploidy(path: Path | None, source_num_snp: int) -> tuple[np.ndarray, np.ndarray]:
|
|
183
|
+
def _parse_ploidy(path: Path | None, source_num_snp: int, source_chr=None) -> tuple[np.ndarray, np.ndarray]:
|
|
184
184
|
if path is None:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
ploidy_male = np.full(source_num_snp, 2.0, dtype=float)
|
|
186
|
+
ploidy_female = np.full(source_num_snp, 2.0, dtype=float)
|
|
187
|
+
if source_chr is not None:
|
|
188
|
+
chrs = np.asarray(source_chr, dtype=object).reshape(-1)
|
|
189
|
+
if chrs.size != source_num_snp:
|
|
190
|
+
raise ValueError("source_chr length must match source_num_snp")
|
|
191
|
+
ploidy_male[chrs == "X"] = 1.0
|
|
192
|
+
return ploidy_male, ploidy_female
|
|
189
193
|
|
|
190
194
|
path = Path(path)
|
|
191
195
|
df = _read_csv_strict(
|
|
@@ -36,13 +36,13 @@ def match_shard_numeric(
|
|
|
36
36
|
shard_label: str,
|
|
37
37
|
*,
|
|
38
38
|
source_name: str = "source",
|
|
39
|
-
) -> np.ndarray:
|
|
39
|
+
) -> tuple[np.ndarray, int]:
|
|
40
40
|
ref_order = np.argsort(ref_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
|
|
41
41
|
ref_sorted = ref_keys[ref_order]
|
|
42
42
|
check_unique_sorted_variant_keys(ref_sorted, f"reference shard {shard_label}", source_name)
|
|
43
43
|
|
|
44
44
|
if src_keys.size == 0:
|
|
45
|
-
return np.full(ref_keys.size, -1, dtype=np.int64)
|
|
45
|
+
return np.full(ref_keys.size, -1, dtype=np.int64), 0
|
|
46
46
|
|
|
47
47
|
src_order = np.argsort(src_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
|
|
48
48
|
src_sorted = src_keys[src_order]
|
|
@@ -55,4 +55,22 @@ def match_shard_numeric(
|
|
|
55
55
|
|
|
56
56
|
out = np.full(ref_keys.size, -1, dtype=np.int64)
|
|
57
57
|
out[matched] = src_order[pos[matched]]
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
matched_src = np.zeros(src_keys.size, dtype=bool)
|
|
60
|
+
matched_src[out[matched]] = True
|
|
61
|
+
unmatched_src = src_keys[~matched_src]
|
|
62
|
+
if unmatched_src.size == 0 or ref_sorted.size == 0:
|
|
63
|
+
return out, 0
|
|
64
|
+
|
|
65
|
+
swapped_src = np.empty(unmatched_src.size, dtype=_KEY_DTYPE)
|
|
66
|
+
swapped_src["bp"] = unmatched_src["bp"]
|
|
67
|
+
swapped_src["a1_hash64"] = unmatched_src["a2_hash64"]
|
|
68
|
+
swapped_src["a2_hash64"] = unmatched_src["a1_hash64"]
|
|
69
|
+
swapped_pos = np.searchsorted(ref_sorted, swapped_src)
|
|
70
|
+
swapped_in_range = swapped_pos < ref_sorted.size
|
|
71
|
+
swapped_match = np.zeros(swapped_src.size, dtype=bool)
|
|
72
|
+
swapped_match[swapped_in_range] = (
|
|
73
|
+
ref_sorted[swapped_pos[swapped_in_range]]
|
|
74
|
+
== swapped_src[swapped_in_range]
|
|
75
|
+
)
|
|
76
|
+
return out, int(np.count_nonzero(swapped_match))
|
|
@@ -20,6 +20,7 @@ from ._variant_match import match_shard_numeric, numeric_variant_keys
|
|
|
20
20
|
|
|
21
21
|
_CACHE_SCHEMA = "genotype_cache/0.1"
|
|
22
22
|
_BED_MISSING_INT8 = np.int8(-1)
|
|
23
|
+
_HAPLOID_MODES = {"raw", "ploidy_scaled"}
|
|
23
24
|
_CACHE_ARRAY_FIELDS = (
|
|
24
25
|
"is_present",
|
|
25
26
|
"ploidy_male",
|
|
@@ -73,9 +74,17 @@ def _normalize_snp_indices(snp_indices, num_snp: int) -> np.ndarray:
|
|
|
73
74
|
)
|
|
74
75
|
return out
|
|
75
76
|
|
|
77
|
+
|
|
78
|
+
def _normalize_haploid_mode(haploid_mode) -> str:
|
|
79
|
+
mode = str(haploid_mode)
|
|
80
|
+
if mode not in _HAPLOID_MODES:
|
|
81
|
+
raise ValueError("GenotypePanel.fetch_genotypes: haploid_mode must be 'raw' or 'ploidy_scaled'")
|
|
82
|
+
return mode
|
|
83
|
+
|
|
76
84
|
@dataclass(frozen=True)
|
|
77
85
|
class _SourceRecord:
|
|
78
86
|
bed_path: Path
|
|
87
|
+
bim_path: Path
|
|
79
88
|
bed_file_size: int
|
|
80
89
|
source_num_snp: int
|
|
81
90
|
source_num_sample: int
|
|
@@ -105,14 +114,20 @@ def _load_source_record(prefix: str, label: str | None) -> _SourceRecord:
|
|
|
105
114
|
source_fam = _parse_fam(fam)
|
|
106
115
|
if label == "X" and ploidy is None:
|
|
107
116
|
warnings.warn(
|
|
108
|
-
f"{prefix}: chrX genotype source has no .ploidy sidecar;
|
|
117
|
+
f"{prefix}: chrX genotype source has no .ploidy sidecar; "
|
|
118
|
+
"defaulting chrX rows to male/female ploidy (1, 2)",
|
|
109
119
|
RuntimeWarning,
|
|
110
120
|
stacklevel=2,
|
|
111
121
|
)
|
|
112
|
-
ploidy_male, ploidy_female = _parse_ploidy(
|
|
122
|
+
ploidy_male, ploidy_female = _parse_ploidy(
|
|
123
|
+
ploidy,
|
|
124
|
+
source_num_snp,
|
|
125
|
+
source_bim["chr"].to_numpy(dtype=object),
|
|
126
|
+
)
|
|
113
127
|
bed_file_size = _validate_bed(bed, source_fam.shape[0], source_num_snp)
|
|
114
128
|
return _SourceRecord(
|
|
115
129
|
bed_path=bed,
|
|
130
|
+
bim_path=bim,
|
|
116
131
|
bed_file_size=bed_file_size,
|
|
117
132
|
source_num_snp=source_num_snp,
|
|
118
133
|
source_num_sample=int(source_fam.shape[0]),
|
|
@@ -466,12 +481,44 @@ class GenotypePanel:
|
|
|
466
481
|
out[np.ix_(panel_rows, cols)] = source_geno[source_subject_rows, :]
|
|
467
482
|
return out
|
|
468
483
|
|
|
469
|
-
def fetch_genotypes(self, snp_indices, bed_path=None):
|
|
484
|
+
def fetch_genotypes(self, snp_indices, bed_path=None, haploid_mode="raw"):
|
|
485
|
+
mode = _normalize_haploid_mode(haploid_mode)
|
|
470
486
|
geno_int8 = self.fetch_genotypes_int8(snp_indices, bed_path=bed_path)
|
|
471
487
|
geno = geno_int8.astype(np.float64)
|
|
472
488
|
geno[geno_int8 == _BED_MISSING_INT8] = np.nan
|
|
489
|
+
if mode == "ploidy_scaled":
|
|
490
|
+
self._apply_ploidy_scaled(geno, _normalize_snp_indices(snp_indices, self._num_snp))
|
|
473
491
|
return geno
|
|
474
492
|
|
|
493
|
+
def _apply_ploidy_scaled(self, geno: np.ndarray, indices: np.ndarray) -> None:
|
|
494
|
+
if indices.size == 0:
|
|
495
|
+
return
|
|
496
|
+
is_male = self._sex == 1
|
|
497
|
+
is_female = self._sex == 2
|
|
498
|
+
is_unknown = self._sex == 0
|
|
499
|
+
addressed = self._addressed_shards(indices)
|
|
500
|
+
for shard, cols, local_indices in addressed:
|
|
501
|
+
ploidy_male = shard.ploidy_male[local_indices]
|
|
502
|
+
ploidy_female = shard.ploidy_female[local_indices]
|
|
503
|
+
differs = ploidy_male != ploidy_female
|
|
504
|
+
unknown_present = is_unknown & shard.subject_present
|
|
505
|
+
if (
|
|
506
|
+
np.any(unknown_present)
|
|
507
|
+
and np.any(differs)
|
|
508
|
+
and np.any(np.isfinite(geno[np.ix_(unknown_present, cols[differs])]))
|
|
509
|
+
):
|
|
510
|
+
raise ValueError(
|
|
511
|
+
"GenotypePanel.fetch_genotypes: haploid_mode='ploidy_scaled' "
|
|
512
|
+
"requires known FAM sex when male and female ploidy differ"
|
|
513
|
+
)
|
|
514
|
+
if np.any(is_male):
|
|
515
|
+
geno[np.ix_(is_male, cols)] *= ploidy_male.reshape(1, -1) / 2.0
|
|
516
|
+
if np.any(is_female):
|
|
517
|
+
geno[np.ix_(is_female, cols)] *= ploidy_female.reshape(1, -1) / 2.0
|
|
518
|
+
if np.any(is_unknown):
|
|
519
|
+
same_ploidy = ploidy_male.reshape(1, -1)
|
|
520
|
+
geno[np.ix_(is_unknown, cols)] *= same_ploidy / 2.0
|
|
521
|
+
|
|
475
522
|
def save_cache(self, path, format=None) -> None:
|
|
476
523
|
save_genotype_cache(self, path, format=format)
|
|
477
524
|
|
|
@@ -536,7 +583,15 @@ def _build_shard(ref_shard, source: _SourceRecord, subject_present, source_subje
|
|
|
536
583
|
source_bim["a1_hash64"].to_numpy(dtype=np.uint64),
|
|
537
584
|
source_bim["a2_hash64"].to_numpy(dtype=np.uint64),
|
|
538
585
|
)
|
|
539
|
-
local_match = match_shard_numeric(ref_keys, src_keys, ref_shard.label, source_name="genotype")
|
|
586
|
+
local_match, n_swapped = match_shard_numeric(ref_keys, src_keys, ref_shard.label, source_name="genotype")
|
|
587
|
+
if n_swapped > 0:
|
|
588
|
+
warnings.warn(
|
|
589
|
+
f"{source.bim_path}: shard {ref_shard.label}: {n_swapped} unmatched genotype "
|
|
590
|
+
"variant(s) would match the reference if a1/a2 were swapped; "
|
|
591
|
+
"variants remain unmatched",
|
|
592
|
+
RuntimeWarning,
|
|
593
|
+
stacklevel=3,
|
|
594
|
+
)
|
|
540
595
|
|
|
541
596
|
is_present = local_match >= 0
|
|
542
597
|
source_row0 = np.full(int(ref_shard.num_snp), -1, dtype=np.int64)
|
|
@@ -589,7 +644,8 @@ def load_genotype(bfile_prefix, reference) -> GenotypePanel:
|
|
|
589
644
|
shared = _load_source_record(prefix, None)
|
|
590
645
|
if "X" in ref_labels and Path(prefix + ".ploidy").is_file() is False:
|
|
591
646
|
warnings.warn(
|
|
592
|
-
f"{prefix}: chrX genotype source has no .ploidy sidecar;
|
|
647
|
+
f"{prefix}: chrX genotype source has no .ploidy sidecar; "
|
|
648
|
+
"defaulting chrX rows to male/female ploidy (1, 2)",
|
|
593
649
|
RuntimeWarning,
|
|
594
650
|
stacklevel=2,
|
|
595
651
|
)
|
|
@@ -12,6 +12,40 @@ from ._utils import CANONICAL_CHR_ORDER, allele_hash64, validate_requested_shard
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
14
|
_CACHE_SCHEMA = "reference_cache/0.1"
|
|
15
|
+
_A_HASH64, _C_HASH64, _G_HASH64, _T_HASH64 = allele_hash64(
|
|
16
|
+
np.array(["A", "C", "G", "T"], dtype=object)
|
|
17
|
+
)
|
|
18
|
+
_SINGLE_BASE_HASH64 = np.array(
|
|
19
|
+
[_A_HASH64, _C_HASH64, _G_HASH64, _T_HASH64], dtype=np.uint64
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_single_nucleotide_variant(a1_hash64, a2_hash64) -> np.ndarray:
|
|
24
|
+
return np.isin(a1_hash64, _SINGLE_BASE_HASH64) & np.isin(
|
|
25
|
+
a2_hash64, _SINGLE_BASE_HASH64
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_strand_ambiguous(a1_hash64, a2_hash64) -> np.ndarray:
|
|
30
|
+
a1 = np.asarray(a1_hash64, dtype=np.uint64).reshape(-1)
|
|
31
|
+
a2 = np.asarray(a2_hash64, dtype=np.uint64).reshape(-1)
|
|
32
|
+
return (
|
|
33
|
+
((a1 == _A_HASH64) & (a2 == _T_HASH64))
|
|
34
|
+
| ((a1 == _T_HASH64) & (a2 == _A_HASH64))
|
|
35
|
+
| ((a1 == _C_HASH64) & (a2 == _G_HASH64))
|
|
36
|
+
| ((a1 == _G_HASH64) & (a2 == _C_HASH64))
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_distinct_alleles(label: str, a1_arr: np.ndarray, a2_arr: np.ndarray) -> None:
|
|
41
|
+
same = np.asarray(a1_arr, dtype=object).reshape(-1) == np.asarray(
|
|
42
|
+
a2_arr, dtype=object
|
|
43
|
+
).reshape(-1)
|
|
44
|
+
if np.any(same):
|
|
45
|
+
idx0 = int(np.flatnonzero(same)[0])
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Reference shard {label} variant {idx0 + 1}: a1 and a2 must differ"
|
|
48
|
+
)
|
|
15
49
|
|
|
16
50
|
|
|
17
51
|
def _checksum_from_arrays(
|
|
@@ -56,6 +90,7 @@ class ReferenceShard:
|
|
|
56
90
|
raise ValueError("ReferenceShard vector lengths must match")
|
|
57
91
|
if self._chr.size and not np.all(self._chr == self._label):
|
|
58
92
|
raise ValueError(f"Reference shard {self._label} contains multiple chr labels")
|
|
93
|
+
_validate_distinct_alleles(self._label, self._a1, self._a2)
|
|
59
94
|
self._a1_hash64 = allele_hash64(self._a1)
|
|
60
95
|
self._a2_hash64 = allele_hash64(self._a2)
|
|
61
96
|
self._checksum = _checksum_from_arrays(self._chr, self._bp, self._a1, self._a2)
|
|
@@ -96,6 +131,14 @@ class ReferenceShard:
|
|
|
96
131
|
def a2_hash64(self) -> np.ndarray:
|
|
97
132
|
return self._a2_hash64
|
|
98
133
|
|
|
134
|
+
@property
|
|
135
|
+
def is_single_nucleotide_variant(self) -> np.ndarray:
|
|
136
|
+
return _is_single_nucleotide_variant(self._a1_hash64, self._a2_hash64)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def is_strand_ambiguous(self) -> np.ndarray:
|
|
140
|
+
return _is_strand_ambiguous(self._a1_hash64, self._a2_hash64)
|
|
141
|
+
|
|
99
142
|
@property
|
|
100
143
|
def checksum(self) -> str:
|
|
101
144
|
return self._checksum
|
|
@@ -130,6 +173,7 @@ class ReferenceShard:
|
|
|
130
173
|
raise ValueError("Invalid reference cache: panel-wide vector lengths mismatch")
|
|
131
174
|
if obj._chr.size and not np.all(obj._chr == obj._label):
|
|
132
175
|
raise ValueError(f"Invalid reference cache: shard {obj._label} contains multiple chr labels")
|
|
176
|
+
_validate_distinct_alleles(obj._label, obj._a1, obj._a2)
|
|
133
177
|
obj._a1_hash64 = (
|
|
134
178
|
allele_hash64(obj._a1)
|
|
135
179
|
if a1_hash64 is None
|
|
@@ -205,6 +249,18 @@ class ReferencePanel:
|
|
|
205
249
|
return np.array([], dtype=np.uint64)
|
|
206
250
|
return np.concatenate([s.a2_hash64 for s in self._shards])
|
|
207
251
|
|
|
252
|
+
@property
|
|
253
|
+
def is_single_nucleotide_variant(self) -> np.ndarray:
|
|
254
|
+
if not self._shards:
|
|
255
|
+
return np.array([], dtype=bool)
|
|
256
|
+
return np.concatenate([s.is_single_nucleotide_variant for s in self._shards])
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def is_strand_ambiguous(self) -> np.ndarray:
|
|
260
|
+
if not self._shards:
|
|
261
|
+
return np.array([], dtype=bool)
|
|
262
|
+
return np.concatenate([s.is_strand_ambiguous for s in self._shards])
|
|
263
|
+
|
|
208
264
|
@property
|
|
209
265
|
def shard_offsets(self) -> list:
|
|
210
266
|
return list(self._shard_offsets)
|
|
@@ -328,7 +328,18 @@ def _build_sumstats_from_aligned(
|
|
|
328
328
|
return Sumstats(shards)
|
|
329
329
|
|
|
330
330
|
|
|
331
|
-
def
|
|
331
|
+
def _warn_swapped_allele_matches(source_path, shard_label: str, count: int, context: str) -> None:
|
|
332
|
+
if count > 0:
|
|
333
|
+
warnings.warn(
|
|
334
|
+
f"{source_path}: shard {shard_label}: {count} unmatched {context} "
|
|
335
|
+
"variant(s) would match the reference if a1/a2 were swapped; "
|
|
336
|
+
"variants remain unmatched",
|
|
337
|
+
RuntimeWarning,
|
|
338
|
+
stacklevel=3,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _build_sumstats_panel(df: pd.DataFrame, reference, source_path) -> Sumstats:
|
|
332
343
|
n = int(reference.num_snp)
|
|
333
344
|
aligned_optional = {
|
|
334
345
|
col: (np.full(n, np.nan, dtype=float) if col in df.columns else None)
|
|
@@ -348,7 +359,8 @@ def _build_sumstats_panel(df: pd.DataFrame, reference) -> Sumstats:
|
|
|
348
359
|
src_idx = np.flatnonzero(src_mask)
|
|
349
360
|
ref_keys = numeric_variant_keys(ref_shard.bp, ref_shard.a1_hash64, ref_shard.a2_hash64)
|
|
350
361
|
src_keys = numeric_variant_keys(src_bp[src_idx], src_a1_hash64[src_idx], src_a2_hash64[src_idx])
|
|
351
|
-
local_match = match_shard_numeric(ref_keys, src_keys, ref_shard.label, source_name="sumstats")
|
|
362
|
+
local_match, n_swapped = match_shard_numeric(ref_keys, src_keys, ref_shard.label, source_name="sumstats")
|
|
363
|
+
_warn_swapped_allele_matches(source_path, ref_shard.label, n_swapped, "sumstats")
|
|
352
364
|
has_match = local_match >= 0
|
|
353
365
|
matched_src = src_idx[local_match[has_match]]
|
|
354
366
|
aligned_ix = np.arange(start, stop, dtype=np.int64)[has_match]
|
|
@@ -375,8 +387,8 @@ def _build_sumstats_panel(df: pd.DataFrame, reference) -> Sumstats:
|
|
|
375
387
|
|
|
376
388
|
|
|
377
389
|
def load_sumstats(path, reference) -> Sumstats:
|
|
378
|
-
|
|
379
|
-
return _build_sumstats_panel(
|
|
390
|
+
path = Path(path)
|
|
391
|
+
return _build_sumstats_panel(_parse_sumstats(path), reference, path)
|
|
380
392
|
|
|
381
393
|
|
|
382
394
|
def create_sumstats(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|