statgen 0.2.0__tar.gz → 0.2.2__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.0
3
+ Version: 0.2.2
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.0"
7
+ version = "0.2.2"
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.2"
@@ -19,30 +19,51 @@ from ._ld_schema import (
19
19
 
20
20
  def validate_npz_distribution(path, check_payload_structure=False) -> dict:
21
21
  path = Path(path)
22
+ if not path.is_dir():
23
+ raise ValueError("validate_npz_distribution: path must identify a panel root directory")
24
+
25
+ manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
26
+ runtime = manifest["runtime_format"]
27
+ seen_reference_bim = set()
28
+ for entry in manifest["shards"]:
29
+ shard_path = path / entry["file"]
30
+ require_file(shard_path)
31
+ file_md5 = md5_file(shard_path)
32
+ if file_md5 != entry.get("file_md5"):
33
+ raise ValueError(f"{shard_path}: file_md5 does not match manifest")
34
+ _ld_r, _a1freq, meta = read_npz_shard(
35
+ shard_path,
36
+ check_payload_structure=check_payload_structure,
37
+ )
38
+ validate_manifest_entry_agreement(entry, meta, shard_path)
39
+ reference_key = (
40
+ entry["reference_bim"],
41
+ entry["chr"],
42
+ int(entry["num_snp"]),
43
+ entry["reference_checksum"],
44
+ )
45
+ if reference_key not in seen_reference_bim:
46
+ _validate_bundled_reference(root=path, entry=entry)
47
+ seen_reference_bim.add(reference_key)
48
+
49
+ return {"ok": True}
22
50
 
23
- if path.is_dir():
24
- manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
25
- runtime = manifest["runtime_format"]
26
- for entry in manifest["shards"]:
27
- shard_path = path / entry["file"]
28
- require_file(shard_path)
29
- file_md5 = md5_file(shard_path)
30
- if file_md5 != entry.get("file_md5"):
31
- raise ValueError(f"{shard_path}: file_md5 does not match manifest")
32
- _ld_r, _a1freq, meta = read_npz_shard(
33
- shard_path,
34
- check_payload_structure=check_payload_structure,
35
- )
36
- validate_manifest_entry_agreement(entry, meta, shard_path)
37
- else:
38
- runtime = runtime_from_suffix(path)
39
- if runtime != PY_RUNTIME_FORMAT:
40
- raise ValueError(f"{path}: unsupported LD runtime {runtime!r}")
41
- read_npz_shard(path, check_payload_structure=check_payload_structure)
42
51
 
52
+ def validate_npz_shard_file(path, check_payload_structure=False) -> dict:
53
+ path = Path(path)
54
+ runtime = runtime_from_suffix(path)
55
+ if runtime != PY_RUNTIME_FORMAT:
56
+ raise ValueError(f"{path}: unsupported LD runtime {runtime!r}")
57
+ read_npz_shard(path, check_payload_structure=check_payload_structure)
43
58
  return {"ok": True}
44
59
 
45
60
 
61
+ def _validate_bundled_reference(*, root: Path, entry: dict) -> None:
62
+ from ._ld_reference import validate_bundled_reference_bim
63
+
64
+ validate_bundled_reference_bim(root / entry["reference_bim"], entry, target="manifest")
65
+
66
+
46
67
  def read_npz_shard(path: Path, check_payload_structure: bool):
47
68
  require_file(path)
48
69
  with np.load(path, allow_pickle=False) as data:
@@ -0,0 +1,57 @@
1
+ from pathlib import Path
2
+
3
+ from ._ld_schema import require_file
4
+ from ._utils import validate_requested_shards
5
+ from .reference import ReferencePanel, load_reference
6
+
7
+
8
+ def validate_bundled_reference_bim(path, entry: dict, *, target: str):
9
+ path = Path(path)
10
+ require_file(path)
11
+ panel = load_reference(path)
12
+ ref_shards = panel.shards
13
+ if len(ref_shards) != 1:
14
+ raise ValueError(f"{path}: bundled reference_bim must contain exactly one shard")
15
+ ref = ref_shards[0]
16
+ validate_reference_shard_for_ld_entry(ref, entry, path, target=target)
17
+ return ref
18
+
19
+
20
+ def validate_reference_shard_for_ld_entry(ref_shard, entry: dict, path, *, target: str) -> None:
21
+ path = Path(path)
22
+ if ref_shard.label != entry["chr"]:
23
+ raise ValueError(f"{path}: bundled reference_bim chr does not match {target}")
24
+ if ref_shard.num_snp != int(entry["num_snp"]):
25
+ raise ValueError(f"{path}: bundled reference_bim num_snp does not match {target}")
26
+ if ref_shard.checksum != entry["reference_checksum"]:
27
+ raise ValueError(f"{path}: bundled reference_bim reference_checksum does not match {target}")
28
+
29
+
30
+ def load_bundled_reference_panel(root, manifest: dict, shards, *, where: str) -> ReferencePanel:
31
+ root = Path(root)
32
+ available = _manifest_chr_labels(manifest)
33
+ selected = validate_requested_shards(shards, available, where)
34
+ entries_by_chr = {}
35
+ for entry in manifest["shards"]:
36
+ entries_by_chr.setdefault(entry["chr"], []).append(entry)
37
+
38
+ ref_shards = []
39
+ for label in selected:
40
+ entries = entries_by_chr[label]
41
+ reference_path = root / entries[0]["reference_bim"]
42
+ ref_shard = validate_bundled_reference_bim(reference_path, entries[0], target="manifest")
43
+ for entry in entries[1:]:
44
+ validate_reference_shard_for_ld_entry(ref_shard, entry, reference_path, target="manifest")
45
+ ref_shards.append(ref_shard)
46
+ return ReferencePanel(ref_shards)
47
+
48
+
49
+ def _manifest_chr_labels(manifest: dict) -> list[str]:
50
+ labels = []
51
+ seen = set()
52
+ for entry in manifest["shards"]:
53
+ label = entry["chr"]
54
+ if label not in seen:
55
+ labels.append(label)
56
+ seen.add(label)
57
+ return labels
@@ -33,17 +33,30 @@ def read_manifest(path: Path, expected_runtime: str | None) -> dict:
33
33
  raise ValueError(f"{path}: shards must be a non-empty list")
34
34
 
35
35
  seen = set()
36
+ reference_bim_by_chr = {}
36
37
  for i, entry in enumerate(shards):
37
38
  validate_manifest_entry(entry, f"{path}:shards[{i}]")
38
39
  key = (entry["chr"], entry["sex"])
39
40
  if key in seen:
40
41
  raise ValueError(f"{path}: duplicate manifest entry for chr {key[0]} sex {key[1]!r}")
41
42
  seen.add(key)
43
+ prior_reference_bim = reference_bim_by_chr.setdefault(entry["chr"], entry["reference_bim"])
44
+ if entry["reference_bim"] != prior_reference_bim:
45
+ raise ValueError(f"{path}: manifest entries for chr {entry['chr']} must share reference_bim")
42
46
  return manifest
43
47
 
44
48
 
45
49
  def validate_manifest_entry(entry: dict, where: str) -> None:
46
- required = {"chr", "sex", "file", "file_md5", "num_snp", "nnz", "reference_checksum"}
50
+ required = {
51
+ "chr",
52
+ "sex",
53
+ "file",
54
+ "file_md5",
55
+ "num_snp",
56
+ "nnz",
57
+ "reference_checksum",
58
+ "reference_bim",
59
+ }
47
60
  missing = sorted(required.difference(entry))
48
61
  if missing:
49
62
  raise ValueError(f"{where}: missing required fields: {', '.join(missing)}")
@@ -58,6 +71,7 @@ def validate_manifest_entry(entry: dict, where: str) -> None:
58
71
  raise ValueError(f"{where}: reference_checksum must be a non-empty string")
59
72
  if not isinstance(entry["file_md5"], str) or len(entry["file_md5"]) != 32:
60
73
  raise ValueError(f"{where}: file_md5 must be a lowercase MD5 hex string")
74
+ validate_reference_bim_filename(entry["reference_bim"], f"{where}: reference_bim")
61
75
 
62
76
 
63
77
  def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> None:
@@ -73,6 +87,8 @@ def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> Non
73
87
  "diagonal",
74
88
  "value",
75
89
  "reference_checksum",
90
+ "reference_bim",
91
+ "num_monomorphic_snps",
76
92
  }
77
93
  missing = sorted(required.difference(meta))
78
94
  if missing:
@@ -86,6 +102,12 @@ def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> Non
86
102
  validate_chr_sex(meta["chr"], meta["sex"], f"{path}: metadata")
87
103
  validate_positive_int(meta["num_snp"], f"{path}: metadata num_snp")
88
104
  validate_nonnegative_int(meta["nnz"], f"{path}: metadata nnz")
105
+ validate_nonnegative_int(
106
+ meta["num_monomorphic_snps"],
107
+ f"{path}: metadata num_monomorphic_snps",
108
+ )
109
+ if int(meta["num_monomorphic_snps"]) > int(meta["num_snp"]):
110
+ raise ValueError(f"{path}: metadata num_monomorphic_snps must not exceed num_snp")
89
111
  if int(meta["nnz"]) >= 2**31:
90
112
  raise ValueError(f"{path}: CSC32 metadata nnz must be < 2^31")
91
113
  if meta["matrix"] != "symmetric":
@@ -96,6 +118,7 @@ def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> Non
96
118
  raise ValueError(f"{path}: metadata value must be 'r'")
97
119
  if not isinstance(meta["reference_checksum"], str) or meta["reference_checksum"] == "":
98
120
  raise ValueError(f"{path}: metadata reference_checksum must be a non-empty string")
121
+ validate_reference_bim_filename(meta["reference_bim"], f"{path}: metadata reference_bim")
99
122
  if expected_format == NPZ_FORMAT:
100
123
  if meta.get("sparse_layout") != "csc":
101
124
  raise ValueError(f"{path}: metadata sparse_layout must be 'csc'")
@@ -119,7 +142,7 @@ def validate_chrx_sex(sex, where: str) -> str:
119
142
 
120
143
 
121
144
  def validate_manifest_entry_agreement(entry: dict, meta: dict, path: Path) -> None:
122
- for key in ("chr", "sex", "num_snp", "nnz", "reference_checksum"):
145
+ for key in ("chr", "sex", "num_snp", "nnz", "reference_checksum", "reference_bim"):
123
146
  if entry.get(key) != meta.get(key):
124
147
  raise ValueError(f"{path}: manifest/per-file metadata mismatch for {key}")
125
148
  suffix_runtime = runtime_from_suffix(path)
@@ -127,6 +150,19 @@ def validate_manifest_entry_agreement(entry: dict, meta: dict, path: Path) -> No
127
150
  raise ValueError(f"{path}: manifest file extension and metadata format disagree")
128
151
 
129
152
 
153
+ def default_reference_bim_filename(chr_label: str) -> str:
154
+ return f"reference_chr{chr_label}.bim"
155
+
156
+
157
+ def validate_reference_bim_filename(value, where: str) -> str:
158
+ if not isinstance(value, str) or value == "":
159
+ raise ValueError(f"{where} must be a non-empty filename")
160
+ path = Path(value)
161
+ if path.is_absolute() or len(path.parts) != 1 or value in {".", ".."} or ".." in value:
162
+ raise ValueError(f"{where} must be a plain relative filename")
163
+ return value
164
+
165
+
130
166
  def runtime_from_suffix(path: Path) -> str:
131
167
  if path.suffix == ".npz":
132
168
  return PY_RUNTIME_FORMAT
@@ -14,11 +14,14 @@ from ._ld_schema import (
14
14
  NPZ_REQUIRED,
15
15
  PY_RUNTIME_FORMAT,
16
16
  SHARD_SCHEMA,
17
+ default_reference_bim_filename,
17
18
  md5_file,
18
19
  validate_chr_sex,
19
20
  validate_positive_int,
21
+ validate_reference_bim_filename,
20
22
  validate_shard_metadata,
21
23
  )
24
+ from ._ld_reference import validate_bundled_reference_bim
22
25
  from ._utils import CHR_RANK
23
26
 
24
27
 
@@ -70,8 +73,14 @@ def write_ld_npz_shards(root, shard_specs) -> list[dict]:
70
73
  raise ValueError(f"duplicate LD shard output file: {file_key}")
71
74
  seen_files.add(file_key)
72
75
 
73
- shard_payload = {k: v for k, v in normalized.items() if k != "file"}
76
+ shard_payload = {
77
+ k: v
78
+ for k, v in normalized.items()
79
+ if k not in {"file", "reference_shard"}
80
+ }
74
81
  meta = _write_ld_npz_shard(path, **shard_payload)
82
+ reference_bim = meta["reference_bim"]
83
+ _ensure_reference_bim(root / reference_bim, normalized["reference_shard"], meta)
75
84
  records.append(
76
85
  {
77
86
  "chr": meta["chr"],
@@ -81,6 +90,7 @@ def write_ld_npz_shards(root, shard_specs) -> list[dict]:
81
90
  "num_snp": meta["num_snp"],
82
91
  "nnz": meta["nnz"],
83
92
  "reference_checksum": meta["reference_checksum"],
93
+ "reference_bim": reference_bim,
84
94
  }
85
95
  )
86
96
  return records
@@ -131,6 +141,7 @@ def _ld_npz_manifest_record(path: Path, root: Path) -> dict:
131
141
  "num_snp": meta["num_snp"],
132
142
  "nnz": meta["nnz"],
133
143
  "reference_checksum": meta["reference_checksum"],
144
+ "reference_bim": _require_reference_bim(root, meta),
134
145
  }
135
146
 
136
147
 
@@ -155,6 +166,7 @@ def _normalize_ld_shard_write_spec(spec: dict) -> dict:
155
166
  "build_metadata",
156
167
  "extra_metadata",
157
168
  "file",
169
+ "reference_bim",
158
170
  }
159
171
  unknown = sorted(set(spec).difference(allowed))
160
172
  if unknown:
@@ -178,12 +190,26 @@ def _normalize_ld_shard_write_spec(spec: dict) -> dict:
178
190
  reference_checksum = spec.get("reference_checksum", getattr(ref, "checksum", None))
179
191
  if not isinstance(reference_checksum, str) or reference_checksum == "":
180
192
  raise ValueError("LD shard write spec reference_checksum must be a non-empty string")
193
+ reference_bim = validate_reference_bim_filename(
194
+ spec.get("reference_bim", default_reference_bim_filename(chr_label)),
195
+ "LD shard write spec reference_bim",
196
+ )
197
+ if ref is None:
198
+ raise ValueError("LD shard write spec must provide reference_shard so reference_bim can be bundled")
199
+ if getattr(ref, "label", None) != chr_label:
200
+ raise ValueError("LD shard write spec reference_shard label must match chr")
201
+ if getattr(ref, "num_snp", None) != num_snp:
202
+ raise ValueError("LD shard write spec reference_shard num_snp must match num_snp")
203
+ if getattr(ref, "checksum", None) != reference_checksum:
204
+ raise ValueError("LD shard write spec reference_shard checksum must match reference_checksum")
181
205
 
182
206
  return {
183
207
  "chr_label": chr_label,
184
208
  "sex": sex,
185
209
  "num_snp": num_snp,
186
210
  "reference_checksum": reference_checksum,
211
+ "reference_shard": ref,
212
+ "reference_bim": reference_bim,
187
213
  "a1freq": _coerce_a1freq(spec.get("a1freq"), num_snp),
188
214
  "ld_pairs": spec.get("ld_pairs"),
189
215
  "build_metadata": dict(spec.get("build_metadata", {})),
@@ -199,6 +225,7 @@ def _write_ld_npz_shard(
199
225
  sex,
200
226
  num_snp,
201
227
  reference_checksum,
228
+ reference_bim,
202
229
  a1freq,
203
230
  ld_pairs,
204
231
  build_metadata=None,
@@ -217,7 +244,9 @@ def _write_ld_npz_shard(
217
244
  sex=sex,
218
245
  num_snp=num_snp,
219
246
  nnz=nnz,
247
+ num_monomorphic_snps=int(np.count_nonzero((a1freq == 0.0) | (a1freq == 1.0))),
220
248
  reference_checksum=reference_checksum,
249
+ reference_bim=reference_bim,
221
250
  build_metadata=build_metadata,
222
251
  extra_metadata=extra_metadata,
223
252
  )
@@ -337,7 +366,9 @@ def _ld_npz_metadata(
337
366
  sex,
338
367
  num_snp,
339
368
  nnz,
369
+ num_monomorphic_snps,
340
370
  reference_checksum,
371
+ reference_bim,
341
372
  build_metadata=None,
342
373
  extra_metadata=None,
343
374
  ) -> dict:
@@ -354,7 +385,9 @@ def _ld_npz_metadata(
354
385
  "matrix": "symmetric",
355
386
  "diagonal": "explicit_unit",
356
387
  "value": "r",
388
+ "num_monomorphic_snps": int(num_monomorphic_snps),
357
389
  "reference_checksum": reference_checksum,
390
+ "reference_bim": reference_bim,
358
391
  }
359
392
  build = dict(LD_BUILD_METADATA_DEFAULTS)
360
393
  if build_metadata:
@@ -362,6 +395,17 @@ def _ld_npz_metadata(
362
395
  meta.update(build)
363
396
  if extra_metadata:
364
397
  meta.update(extra_metadata)
398
+ meta.update(
399
+ {
400
+ "chr": chr_label,
401
+ "sex": sex,
402
+ "num_snp": int(num_snp),
403
+ "nnz": int(nnz),
404
+ "num_monomorphic_snps": int(num_monomorphic_snps),
405
+ "reference_checksum": reference_checksum,
406
+ "reference_bim": reference_bim,
407
+ }
408
+ )
365
409
  validate_shard_metadata(meta, Path("<generated>"), expected_format=NPZ_FORMAT)
366
410
  return meta
367
411
 
@@ -372,6 +416,37 @@ def _default_ld_shard_filename(chr_label: str, sex, suffix: str) -> str:
372
416
  return f"ld_chr{chr_label}{suffix}"
373
417
 
374
418
 
419
+ def _ensure_reference_bim(path: Path, reference_shard, meta: dict) -> None:
420
+ if path.exists():
421
+ _validate_existing_reference_bim(path, meta)
422
+ return
423
+ _write_reference_bim_from_shard(path, reference_shard)
424
+ _validate_existing_reference_bim(path, meta)
425
+
426
+
427
+ def _write_reference_bim_from_shard(path: Path, reference_shard) -> None:
428
+ path.parent.mkdir(parents=True, exist_ok=True)
429
+ with open(path, "w", encoding="utf-8", newline="") as f:
430
+ for chr_label, snp, bp, a1, a2 in zip(
431
+ reference_shard.chr,
432
+ reference_shard.snp,
433
+ reference_shard.bp,
434
+ reference_shard.a1,
435
+ reference_shard.a2,
436
+ ):
437
+ f.write(f"{chr_label}\t{snp}\t0\t{int(bp)}\t{a1}\t{a2}\n")
438
+
439
+
440
+ def _validate_existing_reference_bim(path: Path, meta: dict) -> None:
441
+ validate_bundled_reference_bim(path, meta, target="LD shard")
442
+
443
+
444
+ def _require_reference_bim(root: Path, meta: dict) -> str:
445
+ reference_bim = meta["reference_bim"]
446
+ _validate_existing_reference_bim(root / reference_bim, meta)
447
+ return reference_bim
448
+
449
+
375
450
  def _write_deterministic_npz(path: Path, arrays: dict[str, np.ndarray]) -> None:
376
451
  with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
377
452
  for name, array in arrays.items():
@@ -0,0 +1,77 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+
5
+ CANONICAL_CHR_ORDER = [str(i) for i in range(1, 23)] + ["X"]
6
+ CHR_RANK = {c: i for i, c in enumerate(CANONICAL_CHR_ORDER)}
7
+ _ALLELE_HASH_P = 2_147_483_647
8
+ _ALLELE_HASH_BASE1 = 257
9
+ _ALLELE_HASH_BASE2 = 263
10
+ _ALLELE_HASH_MAX_CHARS = 150
11
+
12
+
13
+ def validate_requested_shards(shards, available_labels, where: str) -> list[str]:
14
+ labels = list(available_labels)
15
+ if shards is None:
16
+ return labels
17
+ if isinstance(shards, str):
18
+ raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
19
+
20
+ requested = list(shards)
21
+ if not requested:
22
+ raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
23
+
24
+ seen = set()
25
+ prev_rank = None
26
+ for label in requested:
27
+ if label not in CHR_RANK:
28
+ raise ValueError(f"{where}: unsupported shard label {label!r}; expected canonical labels 1-22 or X")
29
+ if label in seen:
30
+ raise ValueError(f"{where}: duplicate shard label {label!r} in shards")
31
+ rank = CHR_RANK[label]
32
+ if prev_rank is not None and rank <= prev_rank:
33
+ raise ValueError(f"{where}: shards must be in canonical subsequence order")
34
+ seen.add(label)
35
+ prev_rank = rank
36
+ if label not in labels:
37
+ raise ValueError(f"{where}: requested shard {label!r} is not present")
38
+ return requested
39
+
40
+
41
+ def allele_hash64(alleles) -> np.ndarray:
42
+ arr = np.asarray(alleles, dtype=object).reshape(-1)
43
+ n = arr.size
44
+ if n == 0:
45
+ return np.array([], dtype=np.uint64)
46
+
47
+ strings = [str(allele) for allele in arr]
48
+ lengths = np.fromiter((len(s) for s in strings), dtype=np.int64, count=n)
49
+ if np.any(lengths > _ALLELE_HASH_MAX_CHARS):
50
+ warnings.warn(
51
+ "Allele length exceeds 150 characters; hashing uses first 150 characters",
52
+ RuntimeWarning,
53
+ stacklevel=2,
54
+ )
55
+ strings = [s[:_ALLELE_HASH_MAX_CHARS] for s in strings]
56
+ lengths = np.minimum(lengths, _ALLELE_HASH_MAX_CHARS)
57
+
58
+ encoded = [s.encode("utf-8") for s in strings]
59
+ byte_lengths = np.fromiter((len(b) for b in encoded), dtype=np.int64, count=n)
60
+ max_len = int(byte_lengths.max(initial=0))
61
+ mat = np.zeros((n, max_len), dtype=np.uint8)
62
+ if max_len:
63
+ flat = np.frombuffer(b"".join(encoded), dtype=np.uint8)
64
+ row_idx = np.repeat(np.arange(n, dtype=np.int64), byte_lengths)
65
+ starts = np.cumsum(byte_lengths, dtype=np.int64) - byte_lengths
66
+ col_idx = np.arange(flat.size, dtype=np.int64) - np.repeat(starts, byte_lengths)
67
+ mat[row_idx, col_idx] = flat
68
+
69
+ h1 = np.ones(n, dtype=np.int64)
70
+ h2 = np.ones(n, dtype=np.int64)
71
+ for j in range(max_len):
72
+ active = j < byte_lengths
73
+ x = mat[active, j].astype(np.int64) + 1
74
+ h1[active] = (h1[active] * _ALLELE_HASH_BASE1 + x) % _ALLELE_HASH_P
75
+ h2[active] = (h2[active] * _ALLELE_HASH_BASE2 + x) % _ALLELE_HASH_P
76
+
77
+ return (h1.astype(np.uint64) << np.uint64(32)) + h2.astype(np.uint64)
@@ -1,5 +1,4 @@
1
1
  import json
2
- import io
3
2
  from pathlib import Path
4
3
 
5
4
  import numpy as np
@@ -12,27 +11,6 @@ from ._utils import validate_requested_shards
12
11
  _CACHE_SCHEMA = "annotations_cache/0.1"
13
12
 
14
13
 
15
- def _is_ignored_bed_line(line: str) -> bool:
16
- return (
17
- line == ""
18
- or line.startswith("#")
19
- or line.startswith("track ")
20
- or line.startswith("browser ")
21
- )
22
-
23
-
24
- def _read_bed_payload(path: Path) -> str:
25
- kept = []
26
- with open(path, "r", encoding="utf-8") as f:
27
- for raw in f:
28
- line = raw.rstrip("\r\n")
29
- if _is_ignored_bed_line(line):
30
- continue
31
- kept.append(line)
32
- if not kept:
33
- raise ValueError(f"{path}: BED file is empty")
34
- return "\n".join(kept) + "\n"
35
-
36
14
 
37
15
  def _as_sparse_binary(mat) -> sparse.csr_matrix:
38
16
  if sparse.issparse(mat):
@@ -94,14 +72,16 @@ def _annotation_name_from_path(path: Path) -> str:
94
72
 
95
73
 
96
74
  def _parse_bed(path: Path) -> dict[str, np.ndarray]:
97
- payload = _read_bed_payload(path)
75
+ # Lines beginning with '#' and blank lines are skipped natively.
76
+ # 'track'/'browser' metadata lines are not supported; prefix with '#' if present.
98
77
  try:
99
78
  df = pd.read_csv(
100
- io.StringIO(payload),
101
- sep=r"\s+",
79
+ path,
80
+ sep="\t",
102
81
  header=None,
103
82
  dtype=str,
104
- engine="python",
83
+ comment="#",
84
+ skip_blank_lines=True,
105
85
  keep_default_na=False,
106
86
  na_filter=False,
107
87
  )
@@ -113,7 +93,7 @@ def _parse_bed(path: Path) -> dict[str, np.ndarray]:
113
93
  if df.shape[0] == 0:
114
94
  raise ValueError(f"{path}: BED file is empty")
115
95
  if df.shape[1] < 3:
116
- raise ValueError(f"{path}: BED must have at least 3 whitespace-separated columns")
96
+ raise ValueError(f"{path}: BED must have at least 3 tab-separated columns")
117
97
 
118
98
  chr_col = df.iloc[:, 0]
119
99
  start_raw = df.iloc[:, 1]
@@ -1,4 +1,5 @@
1
1
  from pathlib import Path
2
+ import warnings
2
3
 
3
4
  import numpy as np
4
5
  from scipy import sparse
@@ -15,7 +16,16 @@ from ._utils import validate_requested_shards
15
16
 
16
17
 
17
18
  class LDShard:
18
- def __init__(self, chr_label, sex, num_snp, ld_r, a1freq, reference_checksum):
19
+ def __init__(
20
+ self,
21
+ chr_label,
22
+ sex,
23
+ num_snp,
24
+ ld_r,
25
+ a1freq,
26
+ reference_checksum,
27
+ num_monomorphic_snps=0,
28
+ ):
19
29
  self._chr = str(chr_label)
20
30
  self._sex = sex
21
31
  self._num_snp = int(num_snp)
@@ -23,6 +33,7 @@ class LDShard:
23
33
  self._ld_r2 = _build_ld_r2(self._ld_r)
24
34
  self._a1freq = np.asarray(a1freq, dtype=np.float32).reshape(-1)
25
35
  self._reference_checksum = str(reference_checksum)
36
+ self._num_monomorphic_snps = int(num_monomorphic_snps)
26
37
 
27
38
  @property
28
39
  def chr(self) -> str:
@@ -56,11 +67,16 @@ class LDShard:
56
67
  def checksum(self) -> str:
57
68
  return self._reference_checksum
58
69
 
70
+ @property
71
+ def num_monomorphic_snps(self) -> int:
72
+ return self._num_monomorphic_snps
73
+
59
74
 
60
75
  class LDPanel:
61
- def __init__(self, shard_groups, default_chrX_sex="female"):
76
+ def __init__(self, shard_groups, default_chrX_sex="female", reference=None):
62
77
  self._shard_groups = [list(g) for g in shard_groups]
63
78
  self._default_chrX_sex = validate_chrx_sex(default_chrX_sex, "default_chrX_sex")
79
+ self._reference = reference
64
80
  self._shard_offsets = []
65
81
  pos = 0
66
82
  for group in self._shard_groups:
@@ -77,6 +93,8 @@ class LDPanel:
77
93
  )
78
94
  pos += first.num_snp
79
95
  self._num_snp = pos
96
+ if self._reference is not None:
97
+ self._validate_reference_shape()
80
98
  self._validate_default_chrX_sex()
81
99
 
82
100
  @property
@@ -108,6 +126,10 @@ class LDPanel:
108
126
  def default_chrX_sex(self) -> str:
109
127
  return self._default_chrX_sex
110
128
 
129
+ @property
130
+ def reference(self):
131
+ return self._reference
132
+
111
133
  def a1freq(self, chrX_sex=None) -> np.ndarray:
112
134
  shards = [shard for shard, _start, _stop in self.iter_shards_with_offsets(chrX_sex)]
113
135
  if not shards:
@@ -118,7 +140,8 @@ class LDPanel:
118
140
  available = [group[0].label for group in self._shard_groups]
119
141
  selected = validate_requested_shards(shards, available, "LDPanel.select_shards")
120
142
  by_label = {group[0].label: group for group in self._shard_groups}
121
- return LDPanel([by_label[label] for label in selected], self._default_chrX_sex)
143
+ reference = None if self._reference is None else self._reference.select_shards(selected)
144
+ return LDPanel([by_label[label] for label in selected], self._default_chrX_sex, reference)
122
145
 
123
146
  def multiply_r2(self, M, chrX_sex=None):
124
147
  if sparse.issparse(M):
@@ -156,6 +179,17 @@ class LDPanel:
156
179
  f"got {self._default_chrX_sex!r}, present {sorted(present)!r}"
157
180
  )
158
181
 
182
+ def _validate_reference_shape(self) -> None:
183
+ ref_shards = list(self._reference.shards)
184
+ if len(ref_shards) != len(self._shard_groups):
185
+ raise ValueError("LDPanel reference shard count must match LD shard groups")
186
+ for ref_shard, group in zip(ref_shards, self._shard_groups):
187
+ first = group[0]
188
+ if ref_shard.label != first.label:
189
+ raise ValueError("LDPanel reference shard labels must match LD shard groups")
190
+ if ref_shard.num_snp != first.num_snp:
191
+ raise ValueError("LDPanel reference shard sizes must match LD shard groups")
192
+
159
193
  def _multiply_r2_dense(self, M, chrX_sex=None):
160
194
  arr = np.asarray(M)
161
195
  if arr.ndim == 1:
@@ -213,26 +247,32 @@ class LDPanel:
213
247
  return sparse.vstack(parts, format=M.getformat()).astype(out_dtype)
214
248
 
215
249
 
216
- def load_ld(path, reference, default_chrX_sex=None) -> LDPanel:
250
+ def load_ld(path, reference=None, shards=None, default_chrX_sex=None) -> LDPanel:
217
251
  default_chrX_sex = validate_chrx_sex(
218
252
  "female" if default_chrX_sex is None else default_chrX_sex,
219
253
  "default_chrX_sex",
220
254
  )
221
255
  path = Path(path)
256
+ if not path.is_dir():
257
+ raise ValueError("load_ld: path must identify a panel root directory")
258
+
259
+ manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
260
+ if reference is None:
261
+ reference = _load_bundled_reference(path, manifest, shards)
262
+ elif shards is not None:
263
+ reference = reference.select_shards(shards)
264
+
222
265
  ref_shards = list(reference.shards)
223
266
  if not ref_shards:
224
267
  raise ValueError("load_ld: reference must contain at least one shard")
225
268
 
226
- if path.is_dir():
227
- manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
228
- groups = _load_panel_root(path, manifest, ref_shards)
229
- else:
230
- groups = _load_single_shard(path, ref_shards)
231
-
232
- return LDPanel(groups, default_chrX_sex=default_chrX_sex)
269
+ groups = _load_panel_root(path, manifest, ref_shards)
270
+ return LDPanel(groups, default_chrX_sex=default_chrX_sex, reference=reference)
233
271
 
234
272
 
235
273
  def validate_ld_distribution(path, check_payload_structure=False) -> dict:
274
+ if not Path(path).is_dir():
275
+ raise ValueError("validate_ld_distribution: path must identify a panel root directory")
236
276
  return validate_npz_distribution(path, check_payload_structure=check_payload_structure)
237
277
 
238
278
 
@@ -311,6 +351,12 @@ def _write_ld_npz_distribution(*args, **kwargs):
311
351
  return write_ld_npz_distribution(*args, **kwargs)
312
352
 
313
353
 
354
+ def _load_bundled_reference(root: Path, manifest: dict, shards):
355
+ from ._ld_reference import load_bundled_reference_panel
356
+
357
+ return load_bundled_reference_panel(root, manifest, shards, where="load_ld")
358
+
359
+
314
360
  def _load_panel_root(root: Path, manifest: dict, ref_shards: list) -> list[list[LDShard]]:
315
361
  entries = list(manifest["shards"])
316
362
  groups = []
@@ -346,22 +392,6 @@ def _load_panel_root(root: Path, manifest: dict, ref_shards: list) -> list[list[
346
392
  return groups
347
393
 
348
394
 
349
- def _load_single_shard(path: Path, ref_shards: list) -> list[list[LDShard]]:
350
- shard, _meta = _load_npz_shard(path)
351
- if len(ref_shards) != 1:
352
- raise ValueError(
353
- "single-shard LD loads require a single-shard reference; "
354
- f"reference has {len(ref_shards)} shards"
355
- )
356
- if ref_shards[0].label != shard.chr:
357
- raise ValueError(
358
- "single-shard LD chromosome does not match single-shard reference: "
359
- f"LD chr {shard.chr!r}, reference chr {ref_shards[0].label!r}"
360
- )
361
- _validate_reference_compatibility(shard, ref_shards[0], path)
362
- return [[shard]]
363
-
364
-
365
395
  def _load_npz_shard(path: Path) -> tuple[LDShard, dict]:
366
396
  require_file(path)
367
397
  ld_r, a1freq, meta = read_npz_shard(path, check_payload_structure=False)
@@ -372,7 +402,15 @@ def _load_npz_shard(path: Path) -> tuple[LDShard, dict]:
372
402
  ld_r,
373
403
  a1freq,
374
404
  meta["reference_checksum"],
405
+ int(meta["num_monomorphic_snps"]),
375
406
  )
407
+ if shard.num_monomorphic_snps:
408
+ warnings.warn(
409
+ f"{path}: LD shard contains {shard.num_monomorphic_snps} monomorphic SNPs; "
410
+ "LD involving those SNPs is undefined and represented by omitted off-diagonal entries",
411
+ RuntimeWarning,
412
+ stacklevel=2,
413
+ )
376
414
  return shard, meta
377
415
 
378
416
 
@@ -8,7 +8,7 @@ import numpy as np
8
8
  import pandas as pd
9
9
  from pandas.errors import ParserError
10
10
 
11
- from ._utils import CANONICAL_CHR_ORDER, CHR_RANK, validate_requested_shards
11
+ from ._utils import CANONICAL_CHR_ORDER, CHR_RANK, allele_hash64, validate_requested_shards
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
 
@@ -16,8 +16,6 @@ _CACHE_SCHEMA = "reference_cache/0.1"
16
16
  _BIM_NCOLS = 6
17
17
  _IGNORED_CHR = {"Y", "MT"}
18
18
  _DNA_ALLELE_RE = re.compile(r"^[ACGT]+$")
19
-
20
-
21
19
  def _validate_reference_sort_order(
22
20
  chr_col: pd.Series,
23
21
  bp_col: np.ndarray,
@@ -34,23 +32,21 @@ def _validate_reference_sort_order(
34
32
  f"{path}:{idx + 1}: chr must use canonical labels 1-22 or X"
35
33
  )
36
34
 
37
- order_df = pd.DataFrame(
38
- {
39
- "chr_rank": chr_rank.to_numpy(dtype=np.int64),
40
- "bp": np.asarray(bp_col, dtype=np.int64),
41
- "a1": a1_col.to_numpy(dtype=object),
42
- "a2": a2_col.to_numpy(dtype=object),
43
- "line": np.asarray(line_numbers, dtype=np.int64),
44
- }
45
- )
35
+ order_df = pd.DataFrame({
36
+ "chr_rank": chr_rank.to_numpy(dtype=np.int64),
37
+ "bp": np.asarray(bp_col, dtype=np.int64),
38
+ "a1_hash64": allele_hash64(a1_col.to_numpy(dtype=object)),
39
+ "a2_hash64": allele_hash64(a2_col.to_numpy(dtype=object)),
40
+ "line": np.asarray(line_numbers, dtype=np.int64),
41
+ })
46
42
 
47
43
  dup_mask = order_df.duplicated(
48
- subset=["chr_rank", "bp", "a1", "a2"], keep="first"
44
+ subset=["chr_rank", "bp", "a1_hash64", "a2_hash64"], keep="first"
49
45
  )
50
46
  if dup_mask.any():
51
47
  line = int(order_df.loc[dup_mask.idxmax(), "line"])
52
48
  raise ValueError(
53
- f"{path}:{line}: duplicate (chr, bp, a1, a2) tuple is not allowed"
49
+ f"{path}:{line}: duplicate (chr, bp, a1_hash64, a2_hash64) matching key is not allowed"
54
50
  )
55
51
 
56
52
  prev = order_df.shift(1)
@@ -58,26 +54,14 @@ def _validate_reference_sort_order(
58
54
  (order_df["chr_rank"] < prev["chr_rank"])
59
55
  | (
60
56
  (order_df["chr_rank"] == prev["chr_rank"])
61
- & (
62
- (order_df["bp"] < prev["bp"])
63
- | (
64
- (order_df["bp"] == prev["bp"])
65
- & (
66
- (order_df["a1"] < prev["a1"])
67
- | (
68
- (order_df["a1"] == prev["a1"])
69
- & (order_df["a2"] < prev["a2"])
70
- )
71
- )
72
- )
73
- )
57
+ & (order_df["bp"] < prev["bp"])
74
58
  )
75
59
  )
76
60
  bad_order = bad_order.fillna(False)
77
61
  if bad_order.any():
78
62
  line = int(order_df.loc[bad_order.idxmax(), "line"])
79
63
  raise ValueError(
80
- f"{path}:{line}: rows must be sorted by (chr_rank, bp, a1, a2) in canonical contig order"
64
+ f"{path}:{line}: rows must be sorted by (chr_rank, bp) in canonical contig order"
81
65
  )
82
66
 
83
67
 
@@ -221,6 +205,18 @@ class ReferenceShard:
221
205
  self._bp = np.asarray(bp_arr, dtype=np.int64)
222
206
  self._a1 = np.asarray(a1_arr, dtype=object)
223
207
  self._a2 = np.asarray(a2_arr, dtype=object)
208
+ if not (
209
+ self._chr.size
210
+ == self._snp.size
211
+ == self._bp.size
212
+ == self._a1.size
213
+ == self._a2.size
214
+ ):
215
+ raise ValueError("ReferenceShard vector lengths must match")
216
+ if self._chr.size and not np.all(self._chr == self._label):
217
+ raise ValueError(f"Reference shard {self._label} contains multiple chr labels")
218
+ self._a1_hash64 = allele_hash64(self._a1)
219
+ self._a2_hash64 = allele_hash64(self._a2)
224
220
  self._checksum = _checksum_from_arrays(self._chr, self._bp, self._a1, self._a2)
225
221
 
226
222
  @property
@@ -251,12 +247,31 @@ class ReferenceShard:
251
247
  def a2(self) -> np.ndarray:
252
248
  return self._a2
253
249
 
250
+ @property
251
+ def a1_hash64(self) -> np.ndarray:
252
+ return self._a1_hash64
253
+
254
+ @property
255
+ def a2_hash64(self) -> np.ndarray:
256
+ return self._a2_hash64
257
+
254
258
  @property
255
259
  def checksum(self) -> str:
256
260
  return self._checksum
257
261
 
258
262
  @classmethod
259
- def _from_arrays(cls, label, chr_arr, snp_arr, bp_arr, a1_arr, a2_arr, checksum):
263
+ def _from_arrays(
264
+ cls,
265
+ label,
266
+ chr_arr,
267
+ snp_arr,
268
+ bp_arr,
269
+ a1_arr,
270
+ a2_arr,
271
+ checksum,
272
+ a1_hash64=None,
273
+ a2_hash64=None,
274
+ ):
260
275
  obj = cls.__new__(cls)
261
276
  obj._label = label
262
277
  obj._chr = np.asarray(chr_arr, dtype=object)
@@ -264,6 +279,28 @@ class ReferenceShard:
264
279
  obj._bp = np.asarray(bp_arr, dtype=np.int64)
265
280
  obj._a1 = np.asarray(a1_arr, dtype=object)
266
281
  obj._a2 = np.asarray(a2_arr, dtype=object)
282
+ if not (
283
+ obj._chr.size
284
+ == obj._snp.size
285
+ == obj._bp.size
286
+ == obj._a1.size
287
+ == obj._a2.size
288
+ ):
289
+ raise ValueError("Invalid reference cache: panel-wide vector lengths mismatch")
290
+ if obj._chr.size and not np.all(obj._chr == obj._label):
291
+ raise ValueError(f"Invalid reference cache: shard {obj._label} contains multiple chr labels")
292
+ obj._a1_hash64 = (
293
+ allele_hash64(obj._a1)
294
+ if a1_hash64 is None
295
+ else np.asarray(a1_hash64, dtype=np.uint64).reshape(-1)
296
+ )
297
+ obj._a2_hash64 = (
298
+ allele_hash64(obj._a2)
299
+ if a2_hash64 is None
300
+ else np.asarray(a2_hash64, dtype=np.uint64).reshape(-1)
301
+ )
302
+ if obj._a1_hash64.size != obj._bp.size or obj._a2_hash64.size != obj._bp.size:
303
+ raise ValueError("Invalid reference cache: allele hash vector lengths mismatch")
267
304
  obj._checksum = checksum
268
305
  return obj
269
306
 
@@ -313,6 +350,18 @@ class ReferencePanel:
313
350
  return np.array([], dtype=object)
314
351
  return np.concatenate([s.a2 for s in self._shards])
315
352
 
353
+ @property
354
+ def a1_hash64(self) -> np.ndarray:
355
+ if not self._shards:
356
+ return np.array([], dtype=np.uint64)
357
+ return np.concatenate([s.a1_hash64 for s in self._shards])
358
+
359
+ @property
360
+ def a2_hash64(self) -> np.ndarray:
361
+ if not self._shards:
362
+ return np.array([], dtype=np.uint64)
363
+ return np.concatenate([s.a2_hash64 for s in self._shards])
364
+
316
365
  @property
317
366
  def shard_offsets(self) -> list:
318
367
  return list(self._shard_offsets)
@@ -327,6 +376,13 @@ class ReferencePanel:
327
376
  by_label = {s.label: s for s in self._shards}
328
377
  return ReferencePanel([by_label[label] for label in selected])
329
378
 
379
+ def validate_checksums(self) -> bool:
380
+ for shard in self._shards:
381
+ computed = _checksum_from_arrays(shard.chr, shard.bp, shard.a1, shard.a2)
382
+ if computed != shard.checksum:
383
+ raise ValueError(f"Reference checksum mismatch for shard {shard.label}")
384
+ return True
385
+
330
386
  def is_object_compatible(self, obj) -> bool:
331
387
  log_fn = logger.warning
332
388
 
@@ -441,12 +497,18 @@ def load_reference(path, shards=None) -> ReferencePanel:
441
497
  return ReferencePanel(out_shards)
442
498
 
443
499
 
444
- def save_reference_cache(panel: ReferencePanel, path) -> None:
500
+ def save_reference_cache(panel: ReferencePanel, path, mode: str = "full") -> None:
501
+ mode = str(mode).lower()
502
+ if mode not in {"full", "thin"}:
503
+ raise ValueError("save_reference_cache mode must be 'full' or 'thin'")
504
+
445
505
  # Metadata (schema, labels, checksums) as a compact JSON blob stored in the npz.
446
506
  # SNP-axis numeric vectors (bp) and string arrays are stored as native
447
507
  # binary numpy arrays — not JSON — per the performance contract.
508
+ # Python keeps reference caches full even when mode='thin' is requested.
448
509
  meta = {
449
510
  "schema": _CACHE_SCHEMA,
511
+ "mode": "full",
450
512
  "shard_labels": [s.label for s in panel.shards],
451
513
  "shard_checksums": [s.checksum for s in panel.shards],
452
514
  }
@@ -460,6 +522,8 @@ def save_reference_cache(panel: ReferencePanel, path) -> None:
460
522
  arrays[p + "bp"] = s.bp
461
523
  arrays[p + "a1"] = np.asarray(s.a1, dtype=str)
462
524
  arrays[p + "a2"] = np.asarray(s.a2, dtype=str)
525
+ arrays[p + "a1_hash64"] = s.a1_hash64
526
+ arrays[p + "a2_hash64"] = s.a2_hash64
463
527
  np.savez_compressed(path, **arrays)
464
528
 
465
529
 
@@ -469,6 +533,11 @@ def load_reference_cache(path, shards=None) -> ReferencePanel:
469
533
  schema = meta.get("schema")
470
534
  if schema != _CACHE_SCHEMA:
471
535
  raise ValueError(f"Unsupported reference cache schema: {schema!r}")
536
+ mode = meta.get("mode")
537
+ if mode is None:
538
+ raise ValueError("reference cache missing mode; delete and rebuild old cache")
539
+ if mode != "full":
540
+ raise ValueError(f"Unsupported Python reference cache mode: {mode!r}")
472
541
 
473
542
  labels = list(meta.get("shard_labels", []))
474
543
  checksums = list(meta.get("shard_checksums", []))
@@ -481,6 +550,8 @@ def load_reference_cache(path, shards=None) -> ReferencePanel:
481
550
  for label in selected:
482
551
  i = label_to_index[label]
483
552
  p = f"s{i}_"
553
+ if p + "a1_hash64" not in data or p + "a2_hash64" not in data:
554
+ raise ValueError("reference cache missing a1_hash64/a2_hash64; rebuild cache")
484
555
  shard_objs.append(
485
556
  ReferenceShard._from_arrays(
486
557
  label,
@@ -490,6 +561,8 @@ def load_reference_cache(path, shards=None) -> ReferencePanel:
490
561
  data[p + "a1"],
491
562
  data[p + "a2"],
492
563
  checksums[i],
564
+ data[p + "a1_hash64"],
565
+ data[p + "a2_hash64"],
493
566
  )
494
567
  )
495
568
  return ReferencePanel(shard_objs)
@@ -5,20 +5,77 @@ import numpy as np
5
5
  import pandas as pd
6
6
  from pandas.errors import ParserError
7
7
 
8
- from ._utils import validate_requested_shards
8
+ from ._utils import allele_hash64, validate_requested_shards
9
9
 
10
10
  _CACHE_SCHEMA = "sumstats_cache/0.1"
11
11
  _REQUIRED_COLS = ("chr", "bp", "a1", "a2", "z", "n")
12
12
  _OPTIONAL_COLS = ("p", "beta", "se", "eaf", "info")
13
+ _SUMSTATS_COL_MAP = {
14
+ "pos": "bp",
15
+ "effectallele": "a1",
16
+ "otherallele": "a2",
17
+ }
18
+
19
+
20
+ _KEY_DTYPE = np.dtype([("bp", "<i8"), ("a1_hash64", "<u8"), ("a2_hash64", "<u8")])
21
+
22
+
23
+ def _numeric_keys(bp_arr, a1_hash64, a2_hash64) -> np.ndarray:
24
+ bp = np.asarray(bp_arr, dtype=np.int64).reshape(-1)
25
+ a1h = np.asarray(a1_hash64, dtype=np.uint64).reshape(-1)
26
+ a2h = np.asarray(a2_hash64, dtype=np.uint64).reshape(-1)
27
+ if not (bp.size == a1h.size == a2h.size):
28
+ raise ValueError("numeric key vector lengths mismatch")
29
+ keys = np.empty(bp.size, dtype=_KEY_DTYPE)
30
+ keys["bp"] = bp
31
+ keys["a1_hash64"] = a1h
32
+ keys["a2_hash64"] = a2h
33
+ return keys
34
+
35
+
36
+ def _check_unique_sorted_keys(sorted_keys: np.ndarray, where: str) -> None:
37
+ if sorted_keys.size < 2:
38
+ return
39
+ dup = sorted_keys[1:] == sorted_keys[:-1]
40
+ if np.any(dup):
41
+ pos = int(np.flatnonzero(dup)[0] + 1)
42
+ key = sorted_keys[pos]
43
+ raise ValueError(
44
+ f"Ambiguous duplicate sumstats/reference matching key in {where}: "
45
+ f"bp={int(key['bp'])}, a1_hash64={int(key['a1_hash64'])}, "
46
+ f"a2_hash64={int(key['a2_hash64'])}"
47
+ )
13
48
 
14
49
 
15
- def _key_from_arrays(chr_arr, bp_arr, a1_arr, a2_arr) -> pd.Series:
16
- return (
17
- pd.Series(chr_arr, dtype="string")
18
- .str.cat(pd.Series(bp_arr, dtype=np.int64).astype("string"), sep=":")
19
- .str.cat(pd.Series(a1_arr, dtype="string"), sep=":")
20
- .str.cat(pd.Series(a2_arr, dtype="string"), sep=":")
21
- )
50
+ def _match_shard_numeric(ref_keys: np.ndarray, src_keys: np.ndarray, shard_label: str) -> np.ndarray:
51
+ if src_keys.size == 0:
52
+ return np.full(ref_keys.size, -1, dtype=np.int64)
53
+
54
+ ref_order = np.argsort(ref_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
55
+ src_order = np.argsort(src_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
56
+ ref_sorted = ref_keys[ref_order]
57
+ src_sorted = src_keys[src_order]
58
+ _check_unique_sorted_keys(ref_sorted, f"reference shard {shard_label}")
59
+ _check_unique_sorted_keys(src_sorted, f"sumstats shard {shard_label}")
60
+
61
+ pos = np.searchsorted(src_sorted, ref_keys)
62
+ in_range = pos < src_sorted.size
63
+ matched = np.zeros(ref_keys.size, dtype=bool)
64
+ matched[in_range] = src_sorted[pos[in_range]] == ref_keys[in_range]
65
+
66
+ out = np.full(ref_keys.size, -1, dtype=np.int64)
67
+ out[matched] = src_order[pos[matched]]
68
+ return out
69
+
70
+
71
+ def _canonicalize_columns(df: pd.DataFrame, path: Path) -> pd.DataFrame:
72
+ columns = [_SUMSTATS_COL_MAP.get(str(c).lower(), str(c).lower()) for c in df.columns]
73
+ duplicates = sorted({c for c in columns if columns.count(c) > 1})
74
+ if duplicates:
75
+ raise ValueError(f"{path}: duplicate columns after column normalization: {', '.join(duplicates)}")
76
+ out = df.copy()
77
+ out.columns = columns
78
+ return out
22
79
 
23
80
 
24
81
  def _parse_sumstats(path: Path) -> pd.DataFrame:
@@ -34,6 +91,8 @@ def _parse_sumstats(path: Path) -> pd.DataFrame:
34
91
  except ParserError as exc:
35
92
  raise ValueError(f"{path}: malformed TSV") from exc
36
93
 
94
+ df = _canonicalize_columns(df, path)
95
+
37
96
  missing = [c for c in _REQUIRED_COLS if c not in df.columns]
38
97
  if missing:
39
98
  raise ValueError(f"{path}: missing required columns: {', '.join(missing)}")
@@ -289,40 +348,45 @@ def _build_sumstats_from_aligned(
289
348
 
290
349
 
291
350
  def _build_sumstats_panel(df: pd.DataFrame, reference) -> Sumstats:
292
- ref_chr = np.asarray(reference.chr, dtype=object)
293
- ref_bp = np.asarray(reference.bp, dtype=np.int64)
294
- ref_a1 = np.asarray(reference.a1, dtype=object)
295
- ref_a2 = np.asarray(reference.a2, dtype=object)
296
- ref_keys = _key_from_arrays(ref_chr, ref_bp, ref_a1, ref_a2)
297
-
298
- src_keys = _key_from_arrays(
299
- df["chr"].to_numpy(dtype=object),
300
- df["bp"].to_numpy(dtype=np.int64),
301
- df["a1"].to_numpy(dtype=object),
302
- df["a2"].to_numpy(dtype=object),
303
- )
304
- src = df.copy()
305
- src["_key"] = src_keys
306
-
307
- key_to = {}
308
- for col in ("z", "n") + _OPTIONAL_COLS:
309
- if col in src.columns:
310
- key_to[col] = src.set_index("_key")[col]
311
-
312
- aligned_z = key_to["z"].reindex(ref_keys).to_numpy(dtype=float)
313
- aligned_n = key_to["n"].reindex(ref_keys).to_numpy(dtype=float)
314
- if "p" in key_to:
315
- aligned_p = key_to["p"].reindex(ref_keys).to_numpy(dtype=float)
316
- aligned_logp = _derive_logp(aligned_p)
351
+ n = int(reference.num_snp)
352
+ aligned_z = np.full(n, np.nan, dtype=float)
353
+ aligned_n = np.full(n, np.nan, dtype=float)
354
+ aligned_p = np.full(n, np.nan, dtype=float) if "p" in df.columns else None
355
+
356
+ aligned_optional = {
357
+ col: (np.full(n, np.nan, dtype=float) if col in df.columns else None)
358
+ for col in ("beta", "se", "eaf", "info")
359
+ }
360
+
361
+ src_chr = df["chr"].to_numpy(dtype=object)
362
+ src_bp = df["bp"].to_numpy(dtype=np.int64)
363
+ src_a1_hash64 = allele_hash64(df["a1"].to_numpy(dtype=object))
364
+ src_a2_hash64 = allele_hash64(df["a2"].to_numpy(dtype=object))
365
+
366
+ for ref_shard, off in zip(reference.shards, reference.shard_offsets):
367
+ start = int(off["start0"])
368
+ stop = int(off["stop0"])
369
+ src_mask = src_chr == ref_shard.label
370
+ src_idx = np.flatnonzero(src_mask)
371
+ ref_keys = _numeric_keys(ref_shard.bp, ref_shard.a1_hash64, ref_shard.a2_hash64)
372
+ src_keys = _numeric_keys(src_bp[src_idx], src_a1_hash64[src_idx], src_a2_hash64[src_idx])
373
+ local_match = _match_shard_numeric(ref_keys, src_keys, ref_shard.label)
374
+ has_match = local_match >= 0
375
+ matched_src = src_idx[local_match[has_match]]
376
+ aligned_ix = np.arange(start, stop, dtype=np.int64)[has_match]
377
+
378
+ aligned_z[aligned_ix] = df["z"].to_numpy(dtype=float)[matched_src]
379
+ aligned_n[aligned_ix] = df["n"].to_numpy(dtype=float)[matched_src]
380
+ if aligned_p is not None:
381
+ aligned_p[aligned_ix] = df["p"].to_numpy(dtype=float)[matched_src]
382
+ for col, out in aligned_optional.items():
383
+ if out is not None:
384
+ out[aligned_ix] = df[col].to_numpy(dtype=float)[matched_src]
385
+
386
+ if aligned_p is None:
387
+ aligned_logp = np.full(n, np.nan, dtype=float)
317
388
  else:
318
- aligned_logp = np.full(ref_keys.size, np.nan, dtype=float)
319
-
320
- aligned_optional = {}
321
- for col in ("beta", "se", "eaf", "info"):
322
- if col in key_to:
323
- aligned_optional[col] = key_to[col].reindex(ref_keys).to_numpy(dtype=float)
324
- else:
325
- aligned_optional[col] = None
389
+ aligned_logp = _derive_logp(aligned_p)
326
390
 
327
391
  return _build_sumstats_from_aligned(
328
392
  reference,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statgen
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  statgen/__init__.py
4
4
  statgen/_ld_npz.py
5
+ statgen/_ld_reference.py
5
6
  statgen/_ld_schema.py
6
7
  statgen/_ld_writer.py
7
8
  statgen/_utils.py
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
@@ -1,30 +0,0 @@
1
- CANONICAL_CHR_ORDER = [str(i) for i in range(1, 23)] + ["X"]
2
- CHR_RANK = {c: i for i, c in enumerate(CANONICAL_CHR_ORDER)}
3
-
4
-
5
- def validate_requested_shards(shards, available_labels, where: str) -> list[str]:
6
- labels = list(available_labels)
7
- if shards is None:
8
- return labels
9
- if isinstance(shards, str):
10
- raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
11
-
12
- requested = list(shards)
13
- if not requested:
14
- raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
15
-
16
- seen = set()
17
- prev_rank = None
18
- for label in requested:
19
- if label not in CHR_RANK:
20
- raise ValueError(f"{where}: unsupported shard label {label!r}; expected canonical labels 1-22 or X")
21
- if label in seen:
22
- raise ValueError(f"{where}: duplicate shard label {label!r} in shards")
23
- rank = CHR_RANK[label]
24
- if prev_rank is not None and rank <= prev_rank:
25
- raise ValueError(f"{where}: shards must be in canonical subsequence order")
26
- seen.add(label)
27
- prev_rank = rank
28
- if label not in labels:
29
- raise ValueError(f"{where}: requested shard {label!r} is not present")
30
- return requested
File without changes
File without changes
File without changes