statgen 0.2.3__tar.gz → 0.2.4__tar.gz

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