statgen 0.2.2__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.2 → statgen-0.2.4}/PKG-INFO +1 -1
- {statgen-0.2.2 → statgen-0.2.4}/pyproject.toml +1 -1
- statgen-0.2.4/statgen/__init__.py +1 -0
- statgen-0.2.4/statgen/_bfile_utils.py +275 -0
- statgen-0.2.4/statgen/_variant_match.py +76 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/annotations.py +19 -14
- statgen-0.2.4/statgen/genotype.py +846 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/ld.py +2 -4
- {statgen-0.2.2 → statgen-0.2.4}/statgen/reference.py +59 -155
- {statgen-0.2.2 → statgen-0.2.4}/statgen/sumstats.py +150 -136
- {statgen-0.2.2 → statgen-0.2.4}/statgen.egg-info/PKG-INFO +1 -1
- {statgen-0.2.2 → statgen-0.2.4}/statgen.egg-info/SOURCES.txt +2 -0
- statgen-0.2.2/statgen/__init__.py +0 -1
- statgen-0.2.2/statgen/genotype.py +0 -1
- {statgen-0.2.2 → statgen-0.2.4}/README.md +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/setup.cfg +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/_ld_npz.py +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/_ld_reference.py +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/_ld_schema.py +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/_ld_writer.py +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen/_utils.py +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen.egg-info/dependency_links.txt +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen.egg-info/requires.txt +0 -0
- {statgen-0.2.2 → statgen-0.2.4}/statgen.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.4"
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import warnings
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from pandas.errors import EmptyDataError, ParserError, ParserWarning
|
|
8
|
+
|
|
9
|
+
from ._utils import CANONICAL_CHR_ORDER, CHR_RANK, allele_hash64
|
|
10
|
+
from ._variant_match import check_unique_sorted_variant_keys, numeric_variant_keys
|
|
11
|
+
|
|
12
|
+
IGNORED_CHR = {"Y", "MT"}
|
|
13
|
+
FAM_PUBLIC_COLUMNS = ["fid", "iid", "father_id", "mother_id", "sex"]
|
|
14
|
+
|
|
15
|
+
_BIM_NAMES = ["chr", "snp", "cm", "bp", "a1", "a2"]
|
|
16
|
+
_FAM_NAMES = ["fid", "iid", "father_id", "mother_id", "sex", "pheno"]
|
|
17
|
+
_PLOIDY_NAMES = ["ploidy_male", "ploidy_female"]
|
|
18
|
+
_DNA_ALLELE_RE = re.compile(r"^[ACGT]+$")
|
|
19
|
+
_BED_LOOKUP_INT8 = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_csv_strict(path: Path, *, sep, names, description: str | None = None) -> pd.DataFrame:
|
|
23
|
+
expected = description or f"{len(names)} delimited columns"
|
|
24
|
+
try:
|
|
25
|
+
with warnings.catch_warnings():
|
|
26
|
+
warnings.simplefilter("error", ParserWarning)
|
|
27
|
+
df = pd.read_csv(
|
|
28
|
+
path,
|
|
29
|
+
sep=sep,
|
|
30
|
+
header=None,
|
|
31
|
+
dtype=str,
|
|
32
|
+
index_col=False,
|
|
33
|
+
keep_default_na=False,
|
|
34
|
+
na_filter=False,
|
|
35
|
+
)
|
|
36
|
+
except (EmptyDataError, ParserError, ParserWarning, ValueError) as exc:
|
|
37
|
+
raise ValueError(f"{path}: expected {expected}") from exc
|
|
38
|
+
if df.shape[1] != len(names):
|
|
39
|
+
raise ValueError(f"{path}: expected {expected}, got {df.shape[1]}")
|
|
40
|
+
df.columns = names
|
|
41
|
+
return df
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_source_sort_order(source_df: pd.DataFrame, path: Path) -> None:
|
|
45
|
+
if source_df.empty:
|
|
46
|
+
return
|
|
47
|
+
line = (
|
|
48
|
+
source_df["line"].to_numpy(dtype=np.int64)
|
|
49
|
+
if "line" in source_df.columns
|
|
50
|
+
else source_df.index.to_numpy(dtype=np.int64) + 1
|
|
51
|
+
)
|
|
52
|
+
chr_rank = source_df["chr"].map(CHR_RANK)
|
|
53
|
+
bad_chr = chr_rank.isna()
|
|
54
|
+
if bad_chr.any():
|
|
55
|
+
idx = int(bad_chr.idxmax())
|
|
56
|
+
raise ValueError(f"{path}:{idx + 1}: chr must use canonical labels 1-22 or X")
|
|
57
|
+
|
|
58
|
+
keys = numeric_variant_keys(
|
|
59
|
+
source_df["bp"].to_numpy(dtype=np.int64),
|
|
60
|
+
allele_hash64(source_df["a1"].to_numpy(dtype=object)),
|
|
61
|
+
allele_hash64(source_df["a2"].to_numpy(dtype=object)),
|
|
62
|
+
)
|
|
63
|
+
order_df = pd.DataFrame(
|
|
64
|
+
{
|
|
65
|
+
"chr_rank": chr_rank.to_numpy(dtype=np.int64),
|
|
66
|
+
"bp": source_df["bp"].to_numpy(dtype=np.int64),
|
|
67
|
+
"line": line,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
for rank in np.unique(order_df["chr_rank"].to_numpy(dtype=np.int64)):
|
|
72
|
+
mask = order_df["chr_rank"].to_numpy(dtype=np.int64) == rank
|
|
73
|
+
shard_keys = keys[mask]
|
|
74
|
+
sorted_keys = np.sort(shard_keys, order=("bp", "a1_hash64", "a2_hash64"))
|
|
75
|
+
check_unique_sorted_variant_keys(sorted_keys, f"BIM {path}", "variant")
|
|
76
|
+
|
|
77
|
+
prev = order_df.shift(1)
|
|
78
|
+
bad_order = (
|
|
79
|
+
(order_df["chr_rank"] < prev["chr_rank"])
|
|
80
|
+
| (
|
|
81
|
+
(order_df["chr_rank"] == prev["chr_rank"])
|
|
82
|
+
& (order_df["bp"] < prev["bp"])
|
|
83
|
+
)
|
|
84
|
+
).fillna(False)
|
|
85
|
+
if bad_order.any():
|
|
86
|
+
line = int(order_df.loc[bad_order.idxmax(), "line"])
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"{path}:{line}: rows must be sorted by (chr_rank, bp) in canonical contig order"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_bim(path: Path) -> pd.DataFrame:
|
|
93
|
+
path = Path(path)
|
|
94
|
+
df = _read_csv_strict(
|
|
95
|
+
path,
|
|
96
|
+
sep=r"\s+",
|
|
97
|
+
names=_BIM_NAMES,
|
|
98
|
+
description="6 whitespace-delimited columns",
|
|
99
|
+
)
|
|
100
|
+
df = df.copy()
|
|
101
|
+
|
|
102
|
+
chr_col = df["chr"]
|
|
103
|
+
bad_chr = chr_col.eq("")
|
|
104
|
+
if bad_chr.any():
|
|
105
|
+
idx = int(bad_chr.idxmax())
|
|
106
|
+
raise ValueError(f"{path}:{idx + 1}: chr must be non-empty")
|
|
107
|
+
|
|
108
|
+
chr_style = chr_col.str.lower().str.startswith("chr")
|
|
109
|
+
if chr_style.any():
|
|
110
|
+
idx = int(chr_style.idxmax())
|
|
111
|
+
raise ValueError(f"{path}:{idx + 1}: chr-style labels (e.g., chr1/chrX) are not allowed")
|
|
112
|
+
|
|
113
|
+
known_chr = chr_col.isin(CANONICAL_CHR_ORDER) | chr_col.isin(IGNORED_CHR)
|
|
114
|
+
if not known_chr.all():
|
|
115
|
+
idx = int((~known_chr).idxmax())
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{path}:{idx + 1}: unsupported chr label {chr_col.iat[idx]!r}; expected 1-22, X (Y/MT are ignored)"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
bad_allele = df["a1"].eq("") | df["a2"].eq("")
|
|
121
|
+
if bad_allele.any():
|
|
122
|
+
idx = int(bad_allele.idxmax())
|
|
123
|
+
raise ValueError(f"{path}:{idx + 1}: a1 and a2 must be non-empty")
|
|
124
|
+
|
|
125
|
+
bad_a1_syntax = ~df["a1"].str.fullmatch(_DNA_ALLELE_RE.pattern)
|
|
126
|
+
if bad_a1_syntax.any():
|
|
127
|
+
idx = int(bad_a1_syntax.idxmax())
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"{path}:{idx + 1}: a1 must be uppercase DNA bases (A/C/G/T): {df['a1'].iat[idx]!r}"
|
|
130
|
+
)
|
|
131
|
+
bad_a2_syntax = ~df["a2"].str.fullmatch(_DNA_ALLELE_RE.pattern)
|
|
132
|
+
if bad_a2_syntax.any():
|
|
133
|
+
idx = int(bad_a2_syntax.idxmax())
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"{path}:{idx + 1}: a2 must be uppercase DNA bases (A/C/G/T): {df['a2'].iat[idx]!r}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
cm_num = pd.to_numeric(df["cm"], errors="coerce")
|
|
139
|
+
bad_cm = cm_num.isna()
|
|
140
|
+
if bad_cm.any():
|
|
141
|
+
idx = int(bad_cm.idxmax())
|
|
142
|
+
raise ValueError(f"{path}:{idx + 1}: cm is not a number: {df['cm'].iat[idx]!r}")
|
|
143
|
+
|
|
144
|
+
bp_num = pd.to_numeric(df["bp"], errors="coerce")
|
|
145
|
+
bad_bp = bp_num.isna() | (np.floor(bp_num) != bp_num)
|
|
146
|
+
if bad_bp.any():
|
|
147
|
+
idx = int(bad_bp.idxmax())
|
|
148
|
+
raise ValueError(f"{path}:{idx + 1}: bp is not an integer: {df['bp'].iat[idx]!r}")
|
|
149
|
+
df["bp"] = bp_num.astype(np.int64)
|
|
150
|
+
|
|
151
|
+
_validate_source_sort_order(df.loc[df["chr"].isin(CANONICAL_CHR_ORDER)], path)
|
|
152
|
+
return df.reset_index(drop=True)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_fam(path: Path) -> pd.DataFrame:
|
|
156
|
+
path = Path(path)
|
|
157
|
+
df = _read_csv_strict(
|
|
158
|
+
path,
|
|
159
|
+
sep=r"\s+",
|
|
160
|
+
names=_FAM_NAMES,
|
|
161
|
+
description="6 whitespace-delimited columns",
|
|
162
|
+
)
|
|
163
|
+
required = df[_FAM_NAMES].eq("")
|
|
164
|
+
if required.any().any():
|
|
165
|
+
row = int(np.flatnonzero(required.any(axis=1).to_numpy())[0] + 1)
|
|
166
|
+
raise ValueError(f"{path}:{row}: FAM must contain exactly 6 non-empty columns")
|
|
167
|
+
|
|
168
|
+
sex_num = pd.to_numeric(df["sex"], errors="coerce")
|
|
169
|
+
bad_sex = sex_num.isna() | (np.floor(sex_num) != sex_num) | ~sex_num.isin([0, 1, 2])
|
|
170
|
+
if bad_sex.any():
|
|
171
|
+
idx = int(bad_sex.idxmax())
|
|
172
|
+
raise ValueError(f"{path}:{idx + 1}: FAM sex must be one of PLINK values 1, 2, or 0")
|
|
173
|
+
|
|
174
|
+
out = df[["fid", "iid", "father_id", "mother_id"]].copy()
|
|
175
|
+
out["sex"] = sex_num.astype(np.int8)
|
|
176
|
+
dup = out.duplicated(subset=["fid", "iid"], keep="first")
|
|
177
|
+
if dup.any():
|
|
178
|
+
idx = int(dup.idxmax())
|
|
179
|
+
raise ValueError(f"{path}:{idx + 1}: duplicate FAM subject pair (fid, iid)")
|
|
180
|
+
return out.reset_index(drop=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_ploidy(path: Path | None, source_num_snp: int, source_chr=None) -> tuple[np.ndarray, np.ndarray]:
|
|
184
|
+
if path is None:
|
|
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
|
|
193
|
+
|
|
194
|
+
path = Path(path)
|
|
195
|
+
df = _read_csv_strict(
|
|
196
|
+
path,
|
|
197
|
+
sep="\t",
|
|
198
|
+
names=_PLOIDY_NAMES,
|
|
199
|
+
description="2 tab-delimited columns",
|
|
200
|
+
)
|
|
201
|
+
if df.shape[0] != source_num_snp:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"{path}: PLOIDY row count mismatch: expected {source_num_snp}, got {df.shape[0]}"
|
|
204
|
+
)
|
|
205
|
+
numeric = df[_PLOIDY_NAMES].apply(pd.to_numeric, errors="coerce")
|
|
206
|
+
bad_numeric = numeric.isna()
|
|
207
|
+
if bad_numeric.any().any():
|
|
208
|
+
row = int(np.flatnonzero(bad_numeric.any(axis=1).to_numpy())[0] + 1)
|
|
209
|
+
raise ValueError(f"{path}:{row}: ploidy values must be integers 0, 1, or 2")
|
|
210
|
+
values = numeric.to_numpy(dtype=np.int64)
|
|
211
|
+
bad = ~np.isin(values, [0, 1, 2])
|
|
212
|
+
if bad.any():
|
|
213
|
+
row = int(np.flatnonzero(bad.any(axis=1))[0] + 1)
|
|
214
|
+
raise ValueError(f"{path}:{row}: ploidy values must be 0, 1, or 2")
|
|
215
|
+
return (
|
|
216
|
+
numeric["ploidy_male"].to_numpy(dtype=float),
|
|
217
|
+
numeric["ploidy_female"].to_numpy(dtype=float),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _expected_bed_size(source_num_sample: int, source_num_snp: int) -> int:
|
|
222
|
+
return 3 + ((int(source_num_sample) + 3) // 4) * int(source_num_snp)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validate_bed(path: Path, source_num_sample: int, source_num_snp: int) -> int:
|
|
226
|
+
path = Path(path)
|
|
227
|
+
with open(path, "rb") as f:
|
|
228
|
+
magic = f.read(3)
|
|
229
|
+
if len(magic) != 3 or magic[:2] != b"\x6c\x1b":
|
|
230
|
+
raise ValueError(f"{path}: invalid PLINK BED magic bytes")
|
|
231
|
+
if magic[2] != 1:
|
|
232
|
+
raise ValueError(f"{path}: PLINK BED must be SNP-major mode")
|
|
233
|
+
size = path.stat().st_size
|
|
234
|
+
expected = _expected_bed_size(source_num_sample, source_num_snp)
|
|
235
|
+
if size != expected:
|
|
236
|
+
raise ValueError(f"{path}: BED file size mismatch: expected {expected}, got {size}")
|
|
237
|
+
return int(size)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _bed_lookup_int8() -> np.ndarray:
|
|
241
|
+
global _BED_LOOKUP_INT8
|
|
242
|
+
if _BED_LOOKUP_INT8 is None:
|
|
243
|
+
values = np.arange(256, dtype=np.uint16)[:, None]
|
|
244
|
+
shifts = (np.arange(4, dtype=np.uint16) * 2)[None, :]
|
|
245
|
+
two_bit = ((values >> shifts) & 0b11).astype(np.uint8)
|
|
246
|
+
lookup = np.empty((256, 4), dtype=np.int8)
|
|
247
|
+
lookup[two_bit == 0b00] = 2
|
|
248
|
+
lookup[two_bit == 0b01] = -1
|
|
249
|
+
lookup[two_bit == 0b10] = 1
|
|
250
|
+
lookup[two_bit == 0b11] = 0
|
|
251
|
+
lookup.setflags(write=False)
|
|
252
|
+
_BED_LOOKUP_INT8 = lookup
|
|
253
|
+
return _BED_LOOKUP_INT8
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_bed_rows_int8(path: Path, source_rows: np.ndarray, source_num_sample: int) -> np.ndarray:
|
|
257
|
+
path = Path(path)
|
|
258
|
+
source_rows = np.asarray(source_rows, dtype=np.int64).reshape(-1)
|
|
259
|
+
bytes_per_snp = (int(source_num_sample) + 3) // 4
|
|
260
|
+
decoded = np.empty((int(source_num_sample), source_rows.size), dtype=np.int8)
|
|
261
|
+
lookup = _bed_lookup_int8()
|
|
262
|
+
with open(path, "rb") as f:
|
|
263
|
+
# PERF: loop over requested SNPs retained; each SNP is a separate BED row
|
|
264
|
+
# requiring an individual seek. Inner decode is vectorized across subjects.
|
|
265
|
+
for j, source_row0 in enumerate(source_rows):
|
|
266
|
+
f.seek(3 + int(source_row0) * bytes_per_snp)
|
|
267
|
+
packed = f.read(bytes_per_snp)
|
|
268
|
+
if len(packed) != bytes_per_snp:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"{path}: short BED read for source SNP row {int(source_row0)}: "
|
|
271
|
+
f"expected {bytes_per_snp} bytes, got {len(packed)}"
|
|
272
|
+
)
|
|
273
|
+
byte_values = np.frombuffer(packed, dtype=np.uint8)
|
|
274
|
+
decoded[:, j] = lookup[byte_values].reshape(-1)[:source_num_sample]
|
|
275
|
+
return decoded
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
_KEY_DTYPE = np.dtype([("bp", "<i8"), ("a1_hash64", "<u8"), ("a2_hash64", "<u8")])
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def numeric_variant_keys(bp_arr, a1_hash64, a2_hash64) -> np.ndarray:
|
|
7
|
+
bp = np.asarray(bp_arr, dtype=np.int64).reshape(-1)
|
|
8
|
+
a1h = np.asarray(a1_hash64, dtype=np.uint64).reshape(-1)
|
|
9
|
+
a2h = np.asarray(a2_hash64, dtype=np.uint64).reshape(-1)
|
|
10
|
+
if not (bp.size == a1h.size == a2h.size):
|
|
11
|
+
raise ValueError("numeric variant key vector lengths mismatch")
|
|
12
|
+
keys = np.empty(bp.size, dtype=_KEY_DTYPE)
|
|
13
|
+
keys["bp"] = bp
|
|
14
|
+
keys["a1_hash64"] = a1h
|
|
15
|
+
keys["a2_hash64"] = a2h
|
|
16
|
+
return keys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_unique_sorted_variant_keys(sorted_keys: np.ndarray, where: str, object_name: str = "source") -> None:
|
|
20
|
+
if sorted_keys.size < 2:
|
|
21
|
+
return
|
|
22
|
+
dup = sorted_keys[1:] == sorted_keys[:-1]
|
|
23
|
+
if np.any(dup):
|
|
24
|
+
pos = int(np.flatnonzero(dup)[0] + 1)
|
|
25
|
+
key = sorted_keys[pos]
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Ambiguous duplicate {object_name}/reference matching key in {where}: "
|
|
28
|
+
f"bp={int(key['bp'])}, a1_hash64={int(key['a1_hash64'])}, "
|
|
29
|
+
f"a2_hash64={int(key['a2_hash64'])}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def match_shard_numeric(
|
|
34
|
+
ref_keys: np.ndarray,
|
|
35
|
+
src_keys: np.ndarray,
|
|
36
|
+
shard_label: str,
|
|
37
|
+
*,
|
|
38
|
+
source_name: str = "source",
|
|
39
|
+
) -> tuple[np.ndarray, int]:
|
|
40
|
+
ref_order = np.argsort(ref_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
|
|
41
|
+
ref_sorted = ref_keys[ref_order]
|
|
42
|
+
check_unique_sorted_variant_keys(ref_sorted, f"reference shard {shard_label}", source_name)
|
|
43
|
+
|
|
44
|
+
if src_keys.size == 0:
|
|
45
|
+
return np.full(ref_keys.size, -1, dtype=np.int64), 0
|
|
46
|
+
|
|
47
|
+
src_order = np.argsort(src_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
|
|
48
|
+
src_sorted = src_keys[src_order]
|
|
49
|
+
check_unique_sorted_variant_keys(src_sorted, f"{source_name} shard {shard_label}", source_name)
|
|
50
|
+
|
|
51
|
+
pos = np.searchsorted(src_sorted, ref_keys)
|
|
52
|
+
in_range = pos < src_sorted.size
|
|
53
|
+
matched = np.zeros(ref_keys.size, dtype=bool)
|
|
54
|
+
matched[in_range] = src_sorted[pos[in_range]] == ref_keys[in_range]
|
|
55
|
+
|
|
56
|
+
out = np.full(ref_keys.size, -1, dtype=np.int64)
|
|
57
|
+
out[matched] = src_order[pos[matched]]
|
|
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))
|
|
@@ -203,9 +203,9 @@ def _paint_annotations(bed_paths: list[Path], reference) -> tuple[sparse.csr_mat
|
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
class AnnotationShard:
|
|
206
|
-
def __init__(self, label: str,
|
|
206
|
+
def __init__(self, label: str, reference_checksum: str, annomat):
|
|
207
207
|
self._label = str(label)
|
|
208
|
-
self.
|
|
208
|
+
self._reference_checksum = str(reference_checksum)
|
|
209
209
|
self._annomat = _as_sparse_binary(annomat)
|
|
210
210
|
|
|
211
211
|
@property
|
|
@@ -213,8 +213,8 @@ class AnnotationShard:
|
|
|
213
213
|
return self._label
|
|
214
214
|
|
|
215
215
|
@property
|
|
216
|
-
def
|
|
217
|
-
return self.
|
|
216
|
+
def reference_checksum(self) -> str:
|
|
217
|
+
return self._reference_checksum
|
|
218
218
|
|
|
219
219
|
@property
|
|
220
220
|
def num_snp(self) -> int:
|
|
@@ -229,13 +229,15 @@ class AnnotationShard:
|
|
|
229
229
|
return self._annomat
|
|
230
230
|
|
|
231
231
|
@classmethod
|
|
232
|
-
def _from_arrays(cls, label,
|
|
233
|
-
return cls(label=label,
|
|
232
|
+
def _from_arrays(cls, label, reference_checksum, annomat):
|
|
233
|
+
return cls(label=label, reference_checksum=reference_checksum, annomat=annomat)
|
|
234
234
|
|
|
235
235
|
|
|
236
236
|
class AnnotationPanel:
|
|
237
237
|
def __init__(self, shards: list[AnnotationShard], annonames):
|
|
238
238
|
self._shards = list(shards)
|
|
239
|
+
if not self._shards:
|
|
240
|
+
raise ValueError("AnnotationPanel requires at least one shard")
|
|
239
241
|
self._annonames = np.asarray(_coerce_annonames(annonames), dtype=object)
|
|
240
242
|
self._num_snp = int(sum(s.num_snp for s in self._shards))
|
|
241
243
|
|
|
@@ -297,7 +299,7 @@ class AnnotationPanel:
|
|
|
297
299
|
|
|
298
300
|
idx = np.asarray([idx_map[name] for name in names_list], dtype=np.int64)
|
|
299
301
|
out_shards = [
|
|
300
|
-
AnnotationShard._from_arrays(s.label, s.
|
|
302
|
+
AnnotationShard._from_arrays(s.label, s.reference_checksum, s.annomat[:, idx])
|
|
301
303
|
for s in self._shards
|
|
302
304
|
]
|
|
303
305
|
return AnnotationPanel(out_shards, names_list)
|
|
@@ -322,18 +324,21 @@ class AnnotationPanel:
|
|
|
322
324
|
raise ValueError("union_annotations requires matching shard labels")
|
|
323
325
|
if a.num_snp != getattr(b, "num_snp", None):
|
|
324
326
|
raise ValueError("union_annotations requires matching shard row counts")
|
|
325
|
-
b_checksum = getattr(b, "
|
|
326
|
-
if b_checksum is not None and b_checksum != a.
|
|
327
|
+
b_checksum = getattr(b, "reference_checksum", None)
|
|
328
|
+
if b_checksum is not None and b_checksum != a.reference_checksum:
|
|
327
329
|
raise ValueError("union_annotations requires checksum-compatible reference alignment")
|
|
328
330
|
|
|
329
331
|
b_anno = getattr(b, "annomat", None)
|
|
330
332
|
if b_anno is None:
|
|
331
333
|
raise ValueError("union_annotations requires other shards to expose annomat")
|
|
332
334
|
union_mat = sparse.hstack([a.annomat, _as_sparse_binary(b_anno)], format="csr")
|
|
333
|
-
out_shards.append(AnnotationShard._from_arrays(a.label, a.
|
|
335
|
+
out_shards.append(AnnotationShard._from_arrays(a.label, a.reference_checksum, union_mat))
|
|
334
336
|
|
|
335
337
|
return AnnotationPanel(out_shards, lhs_names + rhs_names)
|
|
336
338
|
|
|
339
|
+
def save_cache(self, path, format=None) -> None:
|
|
340
|
+
save_annotations_cache(self, path, format=format)
|
|
341
|
+
|
|
337
342
|
|
|
338
343
|
def create_annotations(reference, annomat, annonames) -> AnnotationPanel:
|
|
339
344
|
names = _coerce_annonames(annonames)
|
|
@@ -351,7 +356,7 @@ def create_annotations(reference, annomat, annonames) -> AnnotationPanel:
|
|
|
351
356
|
out_shards.append(
|
|
352
357
|
AnnotationShard(
|
|
353
358
|
label=ref_shard.label,
|
|
354
|
-
|
|
359
|
+
reference_checksum=ref_shard.checksum,
|
|
355
360
|
annomat=mat[start:stop, :],
|
|
356
361
|
)
|
|
357
362
|
)
|
|
@@ -382,11 +387,11 @@ def load_annotations(bed_paths, reference) -> AnnotationPanel:
|
|
|
382
387
|
return create_annotations(reference, annomat, annonames)
|
|
383
388
|
|
|
384
389
|
|
|
385
|
-
def save_annotations_cache(panel: AnnotationPanel, path) -> None:
|
|
390
|
+
def save_annotations_cache(panel: AnnotationPanel, path, format=None) -> None:
|
|
386
391
|
meta = {
|
|
387
392
|
"schema": _CACHE_SCHEMA,
|
|
388
393
|
"shard_labels": [s.label for s in panel.shards],
|
|
389
|
-
"shard_checksums": [s.
|
|
394
|
+
"shard_checksums": [s.reference_checksum for s in panel.shards],
|
|
390
395
|
"annonames": panel.annonames.tolist(),
|
|
391
396
|
}
|
|
392
397
|
arrays = {
|
|
@@ -437,7 +442,7 @@ def load_annotations_cache(path, shards=None) -> AnnotationPanel:
|
|
|
437
442
|
shard_objs.append(
|
|
438
443
|
AnnotationShard._from_arrays(
|
|
439
444
|
label=label,
|
|
440
|
-
|
|
445
|
+
reference_checksum=checksums[i],
|
|
441
446
|
annomat=mat,
|
|
442
447
|
)
|
|
443
448
|
)
|