statgen 0.3.0__tar.gz → 0.3.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.3.0
3
+ Version: 0.3.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.3.0"
7
+ version = "0.3.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"
@@ -1,5 +1,5 @@
1
1
  from ._verbosity import get_verbosity, set_verbosity
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
4
4
 
5
5
  __all__ = ["__version__", "get_verbosity", "set_verbosity"]
@@ -135,6 +135,10 @@ def _parse_bim(path: Path) -> pd.DataFrame:
135
135
  raise ValueError(
136
136
  f"{path}:{idx + 1}: a2 must be uppercase DNA bases (A/C/G/T): {df['a2'].iat[idx]!r}"
137
137
  )
138
+ same_allele = df["a1"].eq(df["a2"])
139
+ if same_allele.any():
140
+ idx = int(same_allele.idxmax())
141
+ raise ValueError(f"{path}:{idx + 1}: a1 and a2 must differ")
138
142
 
139
143
  cm_num = pd.to_numeric(df["cm"], errors="coerce")
140
144
  bad_cm = cm_num.isna()
@@ -8,6 +8,14 @@ _ALLELE_HASH_P = 2_147_483_647
8
8
  _ALLELE_HASH_BASE1 = 257
9
9
  _ALLELE_HASH_BASE2 = 263
10
10
  _ALLELE_HASH_MAX_CHARS = 150
11
+ _SINGLE_BASE_BYTES = np.frombuffer(b"ACGT", dtype=np.uint8).astype(np.int64) + 1
12
+ _SINGLE_BASE_HASH64 = (
13
+ (
14
+ ((_ALLELE_HASH_BASE1 + _SINGLE_BASE_BYTES) % _ALLELE_HASH_P).astype(np.uint64)
15
+ << np.uint64(32)
16
+ )
17
+ + ((_ALLELE_HASH_BASE2 + _SINGLE_BASE_BYTES) % _ALLELE_HASH_P).astype(np.uint64)
18
+ )
11
19
 
12
20
 
13
21
  def validate_requested_shards(shards, available_labels, where: str) -> list[str]:
@@ -55,7 +63,38 @@ def allele_hash64(alleles) -> np.ndarray:
55
63
  strings = [s[:_ALLELE_HASH_MAX_CHARS] for s in strings]
56
64
  lengths = np.minimum(lengths, _ALLELE_HASH_MAX_CHARS)
57
65
 
58
- encoded = [s.encode("utf-8") for s in strings]
66
+ out = np.empty(n, dtype=np.uint64)
67
+ single_base = lengths == 1
68
+ if np.any(single_base):
69
+ single_idx = np.flatnonzero(single_base)
70
+ single_strings = np.array([strings[i] for i in single_idx], dtype=object)
71
+ assigned = np.zeros(single_idx.size, dtype=bool)
72
+ for base, hash_value in zip(("A", "C", "G", "T"), _SINGLE_BASE_HASH64):
73
+ matches = single_strings == base
74
+ if np.any(matches):
75
+ out[single_idx[matches]] = hash_value
76
+ assigned[matches] = True
77
+
78
+ if np.any(~assigned):
79
+ other_idx = single_idx[~assigned]
80
+ encoded = [strings[i].encode("utf-8") for i in other_idx]
81
+ h1, h2 = _allele_hash_lanes_from_utf8(encoded)
82
+ out[other_idx] = (
83
+ h1.astype(np.uint64) << np.uint64(32)
84
+ ) + h2.astype(np.uint64)
85
+
86
+ if np.all(single_base):
87
+ return out
88
+
89
+ multi_idx = np.flatnonzero(~single_base)
90
+ encoded = [strings[i].encode("utf-8") for i in multi_idx]
91
+ h1, h2 = _allele_hash_lanes_from_utf8(encoded)
92
+ out[multi_idx] = (h1.astype(np.uint64) << np.uint64(32)) + h2.astype(np.uint64)
93
+ return out
94
+
95
+
96
+ def _allele_hash_lanes_from_utf8(encoded) -> tuple[np.ndarray, np.ndarray]:
97
+ n = len(encoded)
59
98
  byte_lengths = np.fromiter((len(b) for b in encoded), dtype=np.int64, count=n)
60
99
  max_len = int(byte_lengths.max(initial=0))
61
100
  mat = np.zeros((n, max_len), dtype=np.uint8)
@@ -74,4 +113,4 @@ def allele_hash64(alleles) -> np.ndarray:
74
113
  h1[active] = (h1[active] * _ALLELE_HASH_BASE1 + x) % _ALLELE_HASH_P
75
114
  h2[active] = (h2[active] * _ALLELE_HASH_BASE2 + x) % _ALLELE_HASH_P
76
115
 
77
- return (h1.astype(np.uint64) << np.uint64(32)) + h2.astype(np.uint64)
116
+ return h1, h2
@@ -39,17 +39,6 @@ def _is_strand_ambiguous(a1_hash64, a2_hash64) -> np.ndarray:
39
39
  )
40
40
 
41
41
 
42
- def _validate_distinct_alleles(label: str, a1_arr: np.ndarray, a2_arr: np.ndarray) -> None:
43
- same = np.asarray(a1_arr, dtype=object).reshape(-1) == np.asarray(
44
- a2_arr, dtype=object
45
- ).reshape(-1)
46
- if np.any(same):
47
- idx0 = int(np.flatnonzero(same)[0])
48
- raise ValueError(
49
- f"Reference shard {label} variant {idx0 + 1}: a1 and a2 must differ"
50
- )
51
-
52
-
53
42
  def _checksum_from_arrays(
54
43
  chr_arr: np.ndarray, bp_arr: np.ndarray, a1_arr: np.ndarray, a2_arr: np.ndarray
55
44
  ) -> str:
@@ -92,7 +81,6 @@ class ReferenceShard:
92
81
  raise ValueError("ReferenceShard vector lengths must match")
93
82
  if self._chr.size and not np.all(self._chr == self._label):
94
83
  raise ValueError(f"Reference shard {self._label} contains multiple chr labels")
95
- _validate_distinct_alleles(self._label, self._a1, self._a2)
96
84
  self._a1_hash64 = allele_hash64(self._a1)
97
85
  self._a2_hash64 = allele_hash64(self._a2)
98
86
  self._checksum = _checksum_from_arrays(self._chr, self._bp, self._a1, self._a2)
@@ -175,7 +163,6 @@ class ReferenceShard:
175
163
  raise ValueError("Invalid reference cache: panel-wide vector lengths mismatch")
176
164
  if obj._chr.size and not np.all(obj._chr == obj._label):
177
165
  raise ValueError(f"Invalid reference cache: shard {obj._label} contains multiple chr labels")
178
- _validate_distinct_alleles(obj._label, obj._a1, obj._a2)
179
166
  obj._a1_hash64 = (
180
167
  allele_hash64(obj._a1)
181
168
  if a1_hash64 is None
@@ -1,15 +1,19 @@
1
1
  import json
2
+ import re
2
3
  import warnings
4
+ import gzip
3
5
  from pathlib import Path
4
6
 
5
7
  import numpy as np
6
8
  import pandas as pd
7
- from pandas.errors import ParserError
9
+ from pandas.errors import EmptyDataError, ParserError
8
10
 
9
- from ._utils import allele_hash64, validate_requested_shards
11
+ from ._utils import CANONICAL_CHR_ORDER, allele_hash64, validate_requested_shards
10
12
  from ._variant_match import match_shard_numeric, numeric_variant_keys
11
13
 
12
14
  _CACHE_SCHEMA = "sumstats_cache/0.1"
15
+ _IGNORED_CHR = {"Y", "MT"}
16
+ _DNA_ALLELE_RE = re.compile(r"^[ACGT]+$")
13
17
  _REQUIRED_COLS = ("chr", "bp", "a1", "a2", "p")
14
18
  _OPTIONAL_COLS = ("z", "n", "beta", "se", "eaf", "info")
15
19
  _OPTIONAL_VECTOR_FIELDS = ("z", "n", "beta", "se", "eaf", "info")
@@ -18,64 +22,128 @@ _SUMSTATS_COL_MAP = {
18
22
  "effectallele": "a1",
19
23
  "otherallele": "a2",
20
24
  }
25
+ _NUMERIC_NA_VALUES = ["", "NaN", "nan", "NA", "N/A", "na", "NULL", "null"]
21
26
 
22
27
 
23
- def _canonicalize_columns(df: pd.DataFrame, path: Path) -> pd.DataFrame:
24
- columns = [_SUMSTATS_COL_MAP.get(str(c).lower(), str(c).lower()) for c in df.columns]
28
+ def _canonicalize_column_names(columns, path: Path) -> list[str]:
29
+ columns = [_SUMSTATS_COL_MAP.get(str(c).lower(), str(c).lower()) for c in columns]
25
30
  duplicates = sorted({c for c in columns if columns.count(c) > 1})
26
31
  if duplicates:
27
32
  raise ValueError(f"{path}: duplicate columns after column normalization: {', '.join(duplicates)}")
33
+ return columns
34
+
35
+
36
+ def _canonicalize_columns(df: pd.DataFrame, path: Path) -> pd.DataFrame:
28
37
  out = df.copy()
29
- out.columns = columns
38
+ out.columns = _canonicalize_column_names(df.columns, path)
30
39
  return out
31
40
 
32
41
 
42
+ def _read_sumstats_header(path: Path) -> list[str]:
43
+ opener = gzip.open if str(path).lower().endswith(".gz") else open
44
+ try:
45
+ with opener(path, "rt", newline="") as f:
46
+ header = f.readline()
47
+ except OSError as exc:
48
+ raise ValueError(f"{path}: malformed TSV") from exc
49
+ if header == "":
50
+ raise ValueError(f"{path}: malformed TSV")
51
+ return header.rstrip("\r\n").split("\t")
52
+
53
+
33
54
  def _parse_sumstats(path: Path) -> pd.DataFrame:
55
+ raw_columns = _read_sumstats_header(path)
56
+ columns = _canonicalize_column_names(raw_columns, path)
57
+ missing = [c for c in _REQUIRED_COLS if c not in columns]
58
+ if missing:
59
+ raise ValueError(f"{path}: missing required columns: {', '.join(missing)}")
60
+
61
+ selected = [c in (*_REQUIRED_COLS, *_OPTIONAL_COLS) for c in columns]
62
+ selected_raw = [c for c, keep in zip(raw_columns, selected) if keep]
63
+ selected_names = [c for c, keep in zip(columns, selected) if keep]
64
+ char_cols = {
65
+ raw: str
66
+ for raw, canonical in zip(selected_raw, selected_names)
67
+ if canonical in {"chr", "a1", "a2"}
68
+ }
69
+ numeric_cols = {
70
+ raw: float
71
+ for raw, canonical in zip(selected_raw, selected_names)
72
+ if canonical not in {"chr", "a1", "a2"}
73
+ }
74
+ numeric_na_values = {raw: _NUMERIC_NA_VALUES for raw in numeric_cols}
75
+
34
76
  try:
35
77
  df = pd.read_csv(
36
78
  path,
37
79
  sep="\t",
38
80
  compression="infer",
39
- dtype=str,
81
+ usecols=selected_raw,
82
+ dtype={**char_cols, **numeric_cols},
40
83
  keep_default_na=False,
41
- na_filter=False,
84
+ na_values=numeric_na_values,
42
85
  )
43
- except ParserError as exc:
86
+ except (EmptyDataError, ParserError, ValueError) as exc:
44
87
  raise ValueError(f"{path}: malformed TSV") from exc
45
88
 
46
- df = _canonicalize_columns(df, path)
89
+ df.columns = selected_names
47
90
 
48
91
  missing = [c for c in _REQUIRED_COLS if c not in df.columns]
49
92
  if missing:
50
93
  raise ValueError(f"{path}: missing required columns: {', '.join(missing)}")
51
94
 
52
- out = df.copy()
53
- bp_num = pd.to_numeric(out["bp"], errors="coerce")
54
- bad_bp = bp_num.isna() | (np.floor(bp_num) != bp_num)
55
- if bad_bp.any():
56
- idx = int(bad_bp.idxmax())
57
- raise ValueError(f"{path}: row {idx + 2}: bp is not an integer: {out['bp'].iat[idx]!r}")
58
- out["bp"] = bp_num.astype(np.int64)
59
-
95
+ out = df
60
96
  for col in ("chr", "a1", "a2"):
61
97
  bad_empty = out[col].eq("")
62
98
  if bad_empty.any():
63
99
  idx = int(bad_empty.idxmax())
64
100
  raise ValueError(f"{path}: row {idx + 2}: {col} must be non-empty")
65
101
 
66
- p_vals = pd.to_numeric(out["p"], errors="coerce")
67
- p_arr = p_vals.to_numpy(dtype=float)
68
- bad_p = p_vals.isna() | ~np.isfinite(p_arr) | (p_arr < 0.0) | (p_arr > 1.0)
102
+ chr_col = out["chr"]
103
+ chr_style = chr_col.str.lower().str.startswith("chr")
104
+ if chr_style.any():
105
+ idx = int(chr_style.idxmax())
106
+ raise ValueError(f"{path}: row {idx + 2}: chr-style labels (e.g., chr1/chrX) are not allowed")
107
+ known_chr = chr_col.isin(CANONICAL_CHR_ORDER) | chr_col.isin(_IGNORED_CHR)
108
+ if not known_chr.all():
109
+ idx = int((~known_chr).idxmax())
110
+ raise ValueError(
111
+ f"{path}: row {idx + 2}: unsupported chr label {chr_col.iat[idx]!r}; expected 1-22, X (Y/MT are ignored)"
112
+ )
113
+ out = out.loc[chr_col.isin(CANONICAL_CHR_ORDER)].copy()
114
+
115
+ bad_a1_syntax = ~out["a1"].str.fullmatch(_DNA_ALLELE_RE.pattern)
116
+ if bad_a1_syntax.any():
117
+ idx = int(bad_a1_syntax.idxmax())
118
+ raise ValueError(
119
+ f"{path}: row {idx + 2}: a1 must be uppercase DNA bases (A/C/G/T): {out.loc[idx, 'a1']!r}"
120
+ )
121
+ bad_a2_syntax = ~out["a2"].str.fullmatch(_DNA_ALLELE_RE.pattern)
122
+ if bad_a2_syntax.any():
123
+ idx = int(bad_a2_syntax.idxmax())
124
+ raise ValueError(
125
+ f"{path}: row {idx + 2}: a2 must be uppercase DNA bases (A/C/G/T): {out.loc[idx, 'a2']!r}"
126
+ )
127
+ same_allele = out["a1"].eq(out["a2"])
128
+ if same_allele.any():
129
+ idx = int(same_allele.idxmax())
130
+ raise ValueError(f"{path}: row {idx + 2}: a1 and a2 must differ")
131
+
132
+ bad_bp = out["bp"].isna() | (np.floor(out["bp"]) != out["bp"])
133
+ if bad_bp.any():
134
+ idx = int(bad_bp.idxmax())
135
+ raise ValueError(f"{path}: row {idx + 2}: bp is not an integer: {out.loc[idx, 'bp']!r}")
136
+ out["bp"] = out["bp"].astype(np.int64)
137
+
138
+ p_arr = out["p"].to_numpy(dtype=float)
139
+ bad_p = out["p"].isna() | ~np.isfinite(p_arr) | (p_arr < 0.0) | (p_arr > 1.0)
69
140
  if bad_p.any():
70
141
  idx = int(bad_p.idxmax())
71
- raise ValueError(f"{path}: row {idx + 2}: p must be finite numeric in [0, 1]: {out['p'].iat[idx]!r}")
72
- out["p"] = p_vals.astype(float)
142
+ raise ValueError(f"{path}: row {idx + 2}: p must be finite numeric in [0, 1]: {out.loc[idx, 'p']!r}")
73
143
 
74
144
  for opt in _OPTIONAL_COLS:
75
145
  if opt in out.columns:
76
- vals = pd.to_numeric(out[opt], errors="coerce").astype(float)
77
- vals = vals.mask(~np.isfinite(vals.to_numpy(dtype=float)))
78
- out[opt] = vals
146
+ out[opt] = out[opt].mask(~np.isfinite(out[opt].to_numpy(dtype=float)))
79
147
 
80
148
  return out
81
149
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statgen
3
- Version: 0.3.0
3
+ Version: 0.3.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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes