statgen 0.2.6__tar.gz → 0.3.0__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.6
3
+ Version: 0.3.0
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.6"
7
+ version = "0.3.0"
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"
@@ -1,5 +1,5 @@
1
1
  from ._verbosity import get_verbosity, set_verbosity
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  __all__ = ["__version__", "get_verbosity", "set_verbosity"]
@@ -17,6 +17,7 @@ _FAM_NAMES = ["fid", "iid", "father_id", "mother_id", "sex", "pheno"]
17
17
  _PLOIDY_NAMES = ["ploidy_male", "ploidy_female"]
18
18
  _DNA_ALLELE_RE = re.compile(r"^[ACGT]+$")
19
19
  _BED_LOOKUP_INT8 = None
20
+ _BED_MAX_READ_BYTES = 64 * 1024 * 1024
20
21
 
21
22
 
22
23
  def _read_csv_strict(path: Path, *, sep, names, description: str | None = None) -> pd.DataFrame:
@@ -256,20 +257,33 @@ def _bed_lookup_int8() -> np.ndarray:
256
257
  def _read_bed_rows_int8(path: Path, source_rows: np.ndarray, source_num_sample: int) -> np.ndarray:
257
258
  path = Path(path)
258
259
  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)
260
+ n_samples = int(source_num_sample)
261
+ bytes_per_snp = (n_samples + 3) // 4
262
+ if source_rows.size == 0:
263
+ return np.empty((n_samples, 0), dtype=np.int8)
264
+ unique_rows, inverse = np.unique(source_rows, return_inverse=True)
265
+ unique_decoded = np.empty((n_samples, unique_rows.size), dtype=np.int8)
266
+ rows_per_read = unique_rows.size if bytes_per_snp == 0 else max(1, _BED_MAX_READ_BYTES // bytes_per_snp)
261
267
  lookup = _bed_lookup_int8()
262
268
  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
269
+ group_starts = np.r_[0, np.flatnonzero(np.diff(unique_rows) != 1) + 1]
270
+ group_stops = np.r_[group_starts[1:], unique_rows.size]
271
+ # PERF: one seek/read per contiguous BED row block; requested order and
272
+ # repeats are restored after block decoding. Blocks are capped to
273
+ # bound transient packed/decode allocations for large requests.
274
+ for start, stop in zip(group_starts, group_stops, strict=True):
275
+ for chunk_start in range(start, stop, rows_per_read):
276
+ chunk_stop = min(chunk_start + rows_per_read, stop)
277
+ block_rows = unique_rows[chunk_start:chunk_stop]
278
+ expected_bytes = int(bytes_per_snp * block_rows.size)
279
+ f.seek(3 + int(block_rows[0]) * bytes_per_snp)
280
+ packed = f.read(expected_bytes)
281
+ if len(packed) != expected_bytes:
282
+ raise ValueError(
283
+ f"{path}: short BED read for source SNP rows {int(block_rows[0])}-{int(block_rows[-1])}: "
284
+ f"expected {expected_bytes} bytes, got {len(packed)}"
285
+ )
286
+ byte_values = np.frombuffer(packed, dtype=np.uint8)
287
+ block_decoded = lookup[byte_values].reshape(block_rows.size, bytes_per_snp * 4)
288
+ unique_decoded[:, chunk_start:chunk_stop] = block_decoded[:, :n_samples].T
289
+ return unique_decoded[:, inverse]
@@ -12,13 +12,13 @@ _CACHE_SCHEMA = "annotations_cache/0.1"
12
12
 
13
13
 
14
14
 
15
- def _as_sparse_binary(mat) -> sparse.csr_matrix:
15
+ def _as_sparse_binary(mat, name: str = "annomat") -> sparse.csr_matrix:
16
16
  if sparse.issparse(mat):
17
17
  csr = mat.tocsr(copy=True)
18
18
  else:
19
19
  arr = np.asarray(mat)
20
20
  if arr.ndim != 2:
21
- raise ValueError("annomat must be a 2D matrix")
21
+ raise ValueError(f"{name} must be a 2D matrix")
22
22
  csr = sparse.csr_matrix(arr)
23
23
 
24
24
  # Guard against CSR matrices carrying explicit stored zeros in .data.
@@ -30,7 +30,7 @@ def _as_sparse_binary(mat) -> sparse.csr_matrix:
30
30
  bad = data != 1
31
31
  if bad.any():
32
32
  idx = int(np.flatnonzero(bad)[0])
33
- raise ValueError(f"annomat contains non-binary value: {data[idx]!r}")
33
+ raise ValueError(f"{name} contains non-binary value: {data[idx]!r}")
34
34
 
35
35
  csr = csr.astype(np.uint8)
36
36
  csr.data[:] = 1
@@ -336,17 +336,17 @@ class AnnotationPanel:
336
336
 
337
337
  return AnnotationPanel(out_shards, lhs_names + rhs_names)
338
338
 
339
- def save_cache(self, path, format=None) -> None:
340
- save_annotations_cache(self, path, format=format)
339
+ def save_cache(self, path) -> None:
340
+ save_annotations_cache(self, path)
341
341
 
342
342
 
343
- def create_annotations(reference, annomat, annonames) -> AnnotationPanel:
344
- names = _coerce_annonames(annonames)
343
+ def create_annotations(reference, annotation_matrix, annotation_names) -> AnnotationPanel:
344
+ names = _coerce_annonames(annotation_names)
345
345
  n = int(reference.num_snp)
346
- mat = _as_sparse_binary(annomat)
346
+ mat = _as_sparse_binary(annotation_matrix, "annotation_matrix")
347
347
  if mat.shape != (n, len(names)):
348
348
  raise ValueError(
349
- f"annomat shape mismatch: expected ({n}, {len(names)}), got {mat.shape}"
349
+ f"annotation_matrix shape mismatch: expected ({n}, {len(names)}), got {mat.shape}"
350
350
  )
351
351
 
352
352
  out_shards = []
@@ -369,7 +369,7 @@ def create_annotation(reference, annovec, annoname) -> AnnotationPanel:
369
369
  raise ValueError("annoname must be non-empty")
370
370
  vec = _coerce_annovec(annovec, int(reference.num_snp))
371
371
  mat = sparse.csr_matrix(vec.reshape(-1, 1))
372
- return create_annotations(reference, mat, [name])
372
+ return create_annotations(reference, annotation_matrix=mat, annotation_names=[name])
373
373
 
374
374
 
375
375
  def load_annotations(bed_paths, reference) -> AnnotationPanel:
@@ -384,10 +384,10 @@ def load_annotations(bed_paths, reference) -> AnnotationPanel:
384
384
  raise FileNotFoundError(f"BED file not found: {p}")
385
385
 
386
386
  annomat, annonames = _paint_annotations(paths, reference)
387
- return create_annotations(reference, annomat, annonames)
387
+ return create_annotations(reference, annotation_matrix=annomat, annotation_names=annonames)
388
388
 
389
389
 
390
- def save_annotations_cache(panel: AnnotationPanel, path, format=None) -> None:
390
+ def save_annotations_cache(panel: AnnotationPanel, path) -> None:
391
391
  meta = {
392
392
  "schema": _CACHE_SCHEMA,
393
393
  "shard_labels": [s.label for s in panel.shards],
@@ -519,8 +519,8 @@ class GenotypePanel:
519
519
  same_ploidy = ploidy_male.reshape(1, -1)
520
520
  geno[np.ix_(is_unknown, cols)] *= same_ploidy / 2.0
521
521
 
522
- def save_cache(self, path, format=None) -> None:
523
- save_genotype_cache(self, path, format=format)
522
+ def save_cache(self, path) -> None:
523
+ save_genotype_cache(self, path)
524
524
 
525
525
 
526
526
  def _fam_equal(lhs: pd.DataFrame, rhs: pd.DataFrame) -> bool:
@@ -642,7 +642,11 @@ def load_genotype(bfile_prefix, reference) -> GenotypePanel:
642
642
  else:
643
643
  source_layout = "non_sharded"
644
644
  shared = _load_source_record(prefix, None)
645
- if "X" in ref_labels and Path(prefix + ".ploidy").is_file() is False:
645
+ if (
646
+ "X" in ref_labels
647
+ and (shared.bim["chr"] == "X").any()
648
+ and Path(prefix + ".ploidy").is_file() is False
649
+ ):
646
650
  warnings.warn(
647
651
  f"{prefix}: chrX genotype source has no .ploidy sidecar; "
648
652
  "defaulting chrX rows to male/female ploidy (1, 2)",
@@ -692,7 +696,7 @@ def load_genotype(bfile_prefix, reference) -> GenotypePanel:
692
696
  )
693
697
 
694
698
 
695
- def save_genotype_cache(panel: GenotypePanel, path, format=None) -> None:
699
+ def save_genotype_cache(panel: GenotypePanel, path) -> None:
696
700
  meta = {
697
701
  "schema": _CACHE_SCHEMA,
698
702
  "n_shards": len(panel.shards),
@@ -341,8 +341,8 @@ class ReferencePanel:
341
341
  ok = False
342
342
  return ok
343
343
 
344
- def save_cache(self, path, format=None) -> None:
345
- save_reference_cache(self, path, format=format)
344
+ def save_cache(self, path) -> None:
345
+ save_reference_cache(self, path)
346
346
 
347
347
 
348
348
  def load_reference(path, shards=None) -> ReferencePanel:
@@ -403,7 +403,7 @@ def load_reference(path, shards=None) -> ReferencePanel:
403
403
  return ReferencePanel(out_shards)
404
404
 
405
405
 
406
- def save_reference_cache(panel: ReferencePanel, path, format=None) -> None:
406
+ def save_reference_cache(panel: ReferencePanel, path) -> None:
407
407
  # Metadata (schema, labels, checksums) as a compact JSON blob stored in the npz.
408
408
  # SNP-axis numeric vectors (bp) and string arrays are stored as native
409
409
  # binary numpy arrays — not JSON — per the performance contract.
@@ -252,8 +252,8 @@ class Sumstats:
252
252
  by_label = {s.label: s for s in self._shards}
253
253
  return Sumstats([by_label[label] for label in selected])
254
254
 
255
- def save_cache(self, path, format=None) -> None:
256
- save_sumstats_cache(self, path, format=format)
255
+ def save_cache(self, path) -> None:
256
+ save_sumstats_cache(self, path)
257
257
 
258
258
 
259
259
  def _derive_logp(p_vals: np.ndarray) -> np.ndarray:
@@ -393,26 +393,26 @@ def load_sumstats(path, reference) -> Sumstats:
393
393
 
394
394
  def create_sumstats(
395
395
  reference,
396
- pvec,
397
- zvec=None,
398
- nvec=None,
399
- beta_vec=None,
400
- se_vec=None,
401
- eaf_vec=None,
402
- info_vec=None,
396
+ p,
397
+ z=None,
398
+ n=None,
399
+ beta=None,
400
+ se=None,
401
+ eaf=None,
402
+ info=None,
403
403
  ) -> Sumstats:
404
- n = int(reference.num_snp)
405
- aligned_p = _coerce_aligned_vec("pvec", pvec, n=n, allow_inf=True)
406
- _validate_pvec(aligned_p, "pvec")
404
+ num_snp_value = int(reference.num_snp)
405
+ aligned_p = _coerce_aligned_vec("p", p, n=num_snp_value, allow_inf=True)
406
+ _validate_pvec(aligned_p, "p")
407
407
  aligned_logp = _derive_logp(aligned_p)
408
408
 
409
409
  aligned_optional = {
410
- "z": None if zvec is None else _coerce_aligned_vec("zvec", zvec, n=n, allow_inf=False),
411
- "n": None if nvec is None else _coerce_aligned_vec("nvec", nvec, n=n, allow_inf=False),
412
- "beta": None if beta_vec is None else _coerce_aligned_vec("beta_vec", beta_vec, n=n, allow_inf=False),
413
- "se": None if se_vec is None else _coerce_aligned_vec("se_vec", se_vec, n=n, allow_inf=False),
414
- "eaf": None if eaf_vec is None else _coerce_aligned_vec("eaf_vec", eaf_vec, n=n, allow_inf=False),
415
- "info": None if info_vec is None else _coerce_aligned_vec("info_vec", info_vec, n=n, allow_inf=False),
410
+ "z": None if z is None else _coerce_aligned_vec("z", z, n=num_snp_value, allow_inf=False),
411
+ "n": None if n is None else _coerce_aligned_vec("n", n, n=num_snp_value, allow_inf=False),
412
+ "beta": None if beta is None else _coerce_aligned_vec("beta", beta, n=num_snp_value, allow_inf=False),
413
+ "se": None if se is None else _coerce_aligned_vec("se", se, n=num_snp_value, allow_inf=False),
414
+ "eaf": None if eaf is None else _coerce_aligned_vec("eaf", eaf, n=num_snp_value, allow_inf=False),
415
+ "info": None if info is None else _coerce_aligned_vec("info", info, n=num_snp_value, allow_inf=False),
416
416
  }
417
417
 
418
418
  return _build_sumstats_from_aligned(
@@ -422,7 +422,7 @@ def create_sumstats(
422
422
  )
423
423
 
424
424
 
425
- def save_sumstats_cache(sumstats: Sumstats, path, format=None) -> None:
425
+ def save_sumstats_cache(sumstats: Sumstats, path) -> None:
426
426
  meta = {
427
427
  "schema": _CACHE_SCHEMA,
428
428
  "shard_labels": [s.label for s in sumstats.shards],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statgen
3
- Version: 0.2.6
3
+ Version: 0.3.0
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes