statgen 0.2.2__tar.gz → 0.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statgen
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Statistical genetics data objects and loaders for Python and MATLAB/Octave.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "statgen"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Statistical genetics data objects and loaders for Python and MATLAB/Octave."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.3"
@@ -0,0 +1,271 @@
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) -> tuple[np.ndarray, np.ndarray]:
184
+ if path is None:
185
+ return (
186
+ np.full(source_num_snp, 2.0, dtype=float),
187
+ np.full(source_num_snp, 2.0, dtype=float),
188
+ )
189
+
190
+ path = Path(path)
191
+ df = _read_csv_strict(
192
+ path,
193
+ sep="\t",
194
+ names=_PLOIDY_NAMES,
195
+ description="2 tab-delimited columns",
196
+ )
197
+ if df.shape[0] != source_num_snp:
198
+ raise ValueError(
199
+ f"{path}: PLOIDY row count mismatch: expected {source_num_snp}, got {df.shape[0]}"
200
+ )
201
+ numeric = df[_PLOIDY_NAMES].apply(pd.to_numeric, errors="coerce")
202
+ bad_numeric = numeric.isna()
203
+ if bad_numeric.any().any():
204
+ row = int(np.flatnonzero(bad_numeric.any(axis=1).to_numpy())[0] + 1)
205
+ raise ValueError(f"{path}:{row}: ploidy values must be integers 0, 1, or 2")
206
+ values = numeric.to_numpy(dtype=np.int64)
207
+ bad = ~np.isin(values, [0, 1, 2])
208
+ if bad.any():
209
+ row = int(np.flatnonzero(bad.any(axis=1))[0] + 1)
210
+ raise ValueError(f"{path}:{row}: ploidy values must be 0, 1, or 2")
211
+ return (
212
+ numeric["ploidy_male"].to_numpy(dtype=float),
213
+ numeric["ploidy_female"].to_numpy(dtype=float),
214
+ )
215
+
216
+
217
+ def _expected_bed_size(source_num_sample: int, source_num_snp: int) -> int:
218
+ return 3 + ((int(source_num_sample) + 3) // 4) * int(source_num_snp)
219
+
220
+
221
+ def _validate_bed(path: Path, source_num_sample: int, source_num_snp: int) -> int:
222
+ path = Path(path)
223
+ with open(path, "rb") as f:
224
+ magic = f.read(3)
225
+ if len(magic) != 3 or magic[:2] != b"\x6c\x1b":
226
+ raise ValueError(f"{path}: invalid PLINK BED magic bytes")
227
+ if magic[2] != 1:
228
+ raise ValueError(f"{path}: PLINK BED must be SNP-major mode")
229
+ size = path.stat().st_size
230
+ expected = _expected_bed_size(source_num_sample, source_num_snp)
231
+ if size != expected:
232
+ raise ValueError(f"{path}: BED file size mismatch: expected {expected}, got {size}")
233
+ return int(size)
234
+
235
+
236
+ def _bed_lookup_int8() -> np.ndarray:
237
+ global _BED_LOOKUP_INT8
238
+ if _BED_LOOKUP_INT8 is None:
239
+ values = np.arange(256, dtype=np.uint16)[:, None]
240
+ shifts = (np.arange(4, dtype=np.uint16) * 2)[None, :]
241
+ two_bit = ((values >> shifts) & 0b11).astype(np.uint8)
242
+ lookup = np.empty((256, 4), dtype=np.int8)
243
+ lookup[two_bit == 0b00] = 2
244
+ lookup[two_bit == 0b01] = -1
245
+ lookup[two_bit == 0b10] = 1
246
+ lookup[two_bit == 0b11] = 0
247
+ lookup.setflags(write=False)
248
+ _BED_LOOKUP_INT8 = lookup
249
+ return _BED_LOOKUP_INT8
250
+
251
+
252
+ def _read_bed_rows_int8(path: Path, source_rows: np.ndarray, source_num_sample: int) -> np.ndarray:
253
+ path = Path(path)
254
+ source_rows = np.asarray(source_rows, dtype=np.int64).reshape(-1)
255
+ bytes_per_snp = (int(source_num_sample) + 3) // 4
256
+ decoded = np.empty((int(source_num_sample), source_rows.size), dtype=np.int8)
257
+ lookup = _bed_lookup_int8()
258
+ with open(path, "rb") as f:
259
+ # PERF: loop over requested SNPs retained; each SNP is a separate BED row
260
+ # requiring an individual seek. Inner decode is vectorized across subjects.
261
+ for j, source_row0 in enumerate(source_rows):
262
+ f.seek(3 + int(source_row0) * bytes_per_snp)
263
+ packed = f.read(bytes_per_snp)
264
+ if len(packed) != bytes_per_snp:
265
+ raise ValueError(
266
+ f"{path}: short BED read for source SNP row {int(source_row0)}: "
267
+ f"expected {bytes_per_snp} bytes, got {len(packed)}"
268
+ )
269
+ byte_values = np.frombuffer(packed, dtype=np.uint8)
270
+ decoded[:, j] = lookup[byte_values].reshape(-1)[:source_num_sample]
271
+ return decoded
@@ -0,0 +1,58 @@
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
+ ) -> np.ndarray:
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)
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
+ return out
@@ -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, checksum: str, annomat):
206
+ def __init__(self, label: str, reference_checksum: str, annomat):
207
207
  self._label = str(label)
208
- self._checksum = str(checksum)
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 checksum(self) -> str:
217
- return self._checksum
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, checksum, annomat):
233
- return cls(label=label, checksum=checksum, annomat=annomat)
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.checksum, s.annomat[:, idx])
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, "checksum", None)
326
- if b_checksum is not None and b_checksum != a.checksum:
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.checksum, union_mat))
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
- checksum=ref_shard.checksum,
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.checksum for s in panel.shards],
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
- checksum=checksums[i],
445
+ reference_checksum=checksums[i],
441
446
  annomat=mat,
442
447
  )
443
448
  )