sherlock-tools 0.1.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.
- sherlock_tools-0.1.0/.gitignore +13 -0
- sherlock_tools-0.1.0/PKG-INFO +33 -0
- sherlock_tools-0.1.0/README.md +10 -0
- sherlock_tools-0.1.0/pyproject.toml +50 -0
- sherlock_tools-0.1.0/src/sherlock_tools/__init__.py +0 -0
- sherlock_tools-0.1.0/src/sherlock_tools/preprocessing/__init__.py +3 -0
- sherlock_tools-0.1.0/src/sherlock_tools/preprocessing/qc.py +422 -0
- sherlock_tools-0.1.0/tests/test_qc.py +239 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sherlock-tools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library behind the nf-core/sherlock pipeline
|
|
5
|
+
Project-URL: Homepage, https://github.com/guillermocomesanacimadevila/sherlock
|
|
6
|
+
Project-URL: Issues, https://github.com/guillermocomesanacimadevila/sherlock/issues
|
|
7
|
+
Author-email: Guillermo Comesaña Cimadevila <ComesanaCimadevilaG@cardiff.ac.uk>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: liftover>=1.3
|
|
14
|
+
Requires-Dist: polars>=1.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest==9.1.1; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest-cov==7.1.0; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest==9.1.1; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# sherlock-tools
|
|
25
|
+
|
|
26
|
+
Python library used by the nf-core/sherlock pipeline. Scripts in the pipeline's `bin/` folder import it.
|
|
27
|
+
|
|
28
|
+
## Development install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e "sherlock-tools[dev]"
|
|
32
|
+
pytest sherlock-tools/tests
|
|
33
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sherlock-tools"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python library behind the nf-core/sherlock pipeline"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Guillermo Comesaña Cimadevila", email = "ComesanaCimadevilaG@cardiff.ac.uk" }]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"polars>=1.0",
|
|
20
|
+
"liftover>=1.3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = [
|
|
25
|
+
"pytest==9.1.1",
|
|
26
|
+
"pytest-cov==7.1.0",
|
|
27
|
+
]
|
|
28
|
+
dev = [
|
|
29
|
+
"sherlock-tools[test]",
|
|
30
|
+
"ruff>=0.6",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/guillermocomesanacimadevila/sherlock"
|
|
35
|
+
Issues = "https://github.com/guillermocomesanacimadevila/sherlock/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/sherlock_tools"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
pythonpath = ["src"]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
addopts = "-ra"
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 120
|
|
47
|
+
target-version = "py312"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
File without changes
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from liftover import ChainFile
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class QualityControl:
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Object compiling all functions pertaining to GWAS/xQTL QC
|
|
9
|
+
- To do's:
|
|
10
|
+
- Make liftover allele-aware
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
sumstats: pl.DataFrame,
|
|
16
|
+
snp_col: str,
|
|
17
|
+
chr_col: str,
|
|
18
|
+
pos_col: str,
|
|
19
|
+
a1_col: str,
|
|
20
|
+
a2_col: str,
|
|
21
|
+
beta_col: str,
|
|
22
|
+
se_col: str,
|
|
23
|
+
pval_col: str,
|
|
24
|
+
maf_thresh: float,
|
|
25
|
+
genome_build: str,
|
|
26
|
+
target_genome_build: str,
|
|
27
|
+
trait_type: str,
|
|
28
|
+
n_col: str | None = None,
|
|
29
|
+
n: int | None = None,
|
|
30
|
+
n_cases: int | None = None,
|
|
31
|
+
n_controls: int | None = None,
|
|
32
|
+
info_col: str | None = None,
|
|
33
|
+
info_thresh: float | None = None,
|
|
34
|
+
freq_col: str | None = None,
|
|
35
|
+
chain_file: str | None = None, # although in params.yaml arg -> liftover_dir/
|
|
36
|
+
drop_mhc: bool = True,
|
|
37
|
+
drop_apoe: bool = False,
|
|
38
|
+
):
|
|
39
|
+
|
|
40
|
+
# data
|
|
41
|
+
self.sumstats = sumstats
|
|
42
|
+
|
|
43
|
+
# column mapping
|
|
44
|
+
self.snp_col = snp_col
|
|
45
|
+
self.chr_col = chr_col
|
|
46
|
+
self.pos_col = pos_col
|
|
47
|
+
self.a1_col = a1_col
|
|
48
|
+
self.a2_col = a2_col
|
|
49
|
+
self.beta_col = beta_col
|
|
50
|
+
self.se_col = se_col
|
|
51
|
+
self.pval_col = pval_col
|
|
52
|
+
self.info_col = info_col
|
|
53
|
+
self.freq_col = freq_col
|
|
54
|
+
|
|
55
|
+
# trait + sample size
|
|
56
|
+
self.trait_type = trait_type
|
|
57
|
+
self.n_col = n_col
|
|
58
|
+
self.n = n
|
|
59
|
+
self.n_cases = n_cases
|
|
60
|
+
self.n_controls = n_controls
|
|
61
|
+
|
|
62
|
+
# thresholds
|
|
63
|
+
self.maf_thresh = maf_thresh
|
|
64
|
+
self.info_thresh = info_thresh
|
|
65
|
+
|
|
66
|
+
# builds
|
|
67
|
+
self.genome_build = genome_build
|
|
68
|
+
self.target_genome_build = target_genome_build
|
|
69
|
+
self.chain_file = chain_file
|
|
70
|
+
|
|
71
|
+
# region removal
|
|
72
|
+
self.drop_mhc = drop_mhc
|
|
73
|
+
self.drop_apoe = drop_apoe
|
|
74
|
+
|
|
75
|
+
def _fix_data_types(self):
|
|
76
|
+
# strict=False -> values that can't be cast become null (caught by _correct_for_missingness)
|
|
77
|
+
df = self.sumstats.with_columns(
|
|
78
|
+
pl.col(self.snp_col).cast(pl.String),
|
|
79
|
+
pl.col(self.chr_col).cast(pl.String).str.replace(r"(?i)^chr", ""),
|
|
80
|
+
pl.col(self.pos_col).cast(pl.Int64, strict=False),
|
|
81
|
+
pl.col(self.a1_col).cast(pl.String).str.to_uppercase(),
|
|
82
|
+
pl.col(self.a2_col).cast(pl.String).str.to_uppercase(),
|
|
83
|
+
pl.col(self.beta_col).cast(pl.Float64, strict=False),
|
|
84
|
+
pl.col(self.se_col).cast(pl.Float64, strict=False),
|
|
85
|
+
pl.col(self.pval_col).cast(pl.Float64, strict=False),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# optional cols if given
|
|
89
|
+
optional_cols = [c for c in [self.n_col, self.freq_col, self.info_col] if c is not None]
|
|
90
|
+
df = df.with_columns(pl.col(c).cast(pl.Float64, strict=False) for c in optional_cols)
|
|
91
|
+
self.sumstats = df
|
|
92
|
+
return df
|
|
93
|
+
|
|
94
|
+
def _fix_pval(self):
|
|
95
|
+
# P == 0 (underflow) -> 1e-300, so downstream Z / -log10(P) stay finite
|
|
96
|
+
n_zero = self.sumstats.filter(pl.col(self.pval_col) == 0).height
|
|
97
|
+
df = self.sumstats.with_columns(
|
|
98
|
+
pl.when(pl.col(self.pval_col) == 0)
|
|
99
|
+
.then(1e-300)
|
|
100
|
+
.otherwise(pl.col(self.pval_col))
|
|
101
|
+
.alias(self.pval_col)
|
|
102
|
+
)
|
|
103
|
+
print(f"Replaced P == 0 with 1e-300: {n_zero}")
|
|
104
|
+
self.sumstats = df
|
|
105
|
+
return df
|
|
106
|
+
|
|
107
|
+
def _restrict_to_autosomal(self):
|
|
108
|
+
# keep chr 1-22 only ("chr" prefix already stripped in _fix_data_types)
|
|
109
|
+
autosomes = [str(i) for i in range(1, 23)]
|
|
110
|
+
n_before = self.sumstats.height
|
|
111
|
+
df = self.sumstats.filter(pl.col(self.chr_col).is_in(autosomes))
|
|
112
|
+
n_after = df.height
|
|
113
|
+
print(f"Removed non-autosomal variants: {n_before - n_after}")
|
|
114
|
+
print(f"Rows after autosome restriction: {n_after}")
|
|
115
|
+
self.sumstats = df
|
|
116
|
+
return df
|
|
117
|
+
|
|
118
|
+
def _correct_for_missingness(self):
|
|
119
|
+
n_before = self.sumstats.height
|
|
120
|
+
df = self.sumstats.filter(~pl.all_horizontal(pl.all().is_null()))
|
|
121
|
+
n_after = df.height
|
|
122
|
+
print(f"Removed empty rows: {n_before - n_after}")
|
|
123
|
+
print(f"Rows after empty-row removal: {n_after}")
|
|
124
|
+
|
|
125
|
+
# remove rows missing any key column (+ optional cols if given)
|
|
126
|
+
key_cols = [
|
|
127
|
+
self.snp_col, self.chr_col, self.pos_col,
|
|
128
|
+
self.a1_col, self.a2_col,
|
|
129
|
+
self.beta_col, self.se_col, self.pval_col,
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
key_cols += [c for c in [self.n_col, self.freq_col, self.info_col] if c is not None]
|
|
133
|
+
n_before = df.height
|
|
134
|
+
df = df.drop_nulls(subset=key_cols)
|
|
135
|
+
n_after = df.height
|
|
136
|
+
print(f"Removed rows with missing key values: {n_before - n_after}")
|
|
137
|
+
print(f"Rows after missing-value removal: {n_after}")
|
|
138
|
+
self.sumstats = df
|
|
139
|
+
return df
|
|
140
|
+
|
|
141
|
+
def _remove_duplicates(self):
|
|
142
|
+
# duplicated SNP IDs -> drop all copies (can't tell which one is right)
|
|
143
|
+
n_before = self.sumstats.height
|
|
144
|
+
df = self.sumstats.unique(subset=[self.snp_col], keep="none", maintain_order=True)
|
|
145
|
+
n_after = df.height
|
|
146
|
+
print(f"Removed duplicated SNPs: {n_before - n_after}")
|
|
147
|
+
print(f"Rows after duplicate removal: {n_after}")
|
|
148
|
+
self.sumstats = df
|
|
149
|
+
return df
|
|
150
|
+
|
|
151
|
+
def _remove_multiallelic(self):
|
|
152
|
+
# run after duplicate removal -> any repeated CHR:POS left = different alleles
|
|
153
|
+
n_before = self.sumstats.height
|
|
154
|
+
df = self.sumstats.filter(
|
|
155
|
+
pl.len().over([self.chr_col, self.pos_col]) == 1
|
|
156
|
+
)
|
|
157
|
+
n_after = df.height
|
|
158
|
+
print(f"Removed multiallelic variants: {n_before - n_after}")
|
|
159
|
+
print(f"Rows after multiallelic removal: {n_after}")
|
|
160
|
+
self.sumstats = df
|
|
161
|
+
return df
|
|
162
|
+
|
|
163
|
+
def _remove_indels(self):
|
|
164
|
+
bases = ["A", "T", "C", "G"]
|
|
165
|
+
a1 = pl.col(self.a1_col)
|
|
166
|
+
a2 = pl.col(self.a2_col)
|
|
167
|
+
ok_len = (a1.str.len_chars() == 1) & (a2.str.len_chars() == 1)
|
|
168
|
+
ok_bases = a1.is_in(bases) & a2.is_in(bases)
|
|
169
|
+
n_before = self.sumstats.height
|
|
170
|
+
df = self.sumstats.filter(ok_len & ok_bases)
|
|
171
|
+
n_after = df.height
|
|
172
|
+
print(f"Removed INDELs / non-ACGT: {n_before - n_after}")
|
|
173
|
+
print(f"Rows after INDEL removal: {n_after}")
|
|
174
|
+
self.sumstats = df
|
|
175
|
+
return df
|
|
176
|
+
|
|
177
|
+
def _remove_palindromes(self):
|
|
178
|
+
# A/T and C/G SNPs -> strand can't be resolved, always dropped
|
|
179
|
+
a1 = pl.col(self.a1_col)
|
|
180
|
+
a2 = pl.col(self.a2_col)
|
|
181
|
+
is_palindrome = (
|
|
182
|
+
((a1 == "A") & (a2 == "T")) |
|
|
183
|
+
((a1 == "T") & (a2 == "A")) |
|
|
184
|
+
((a1 == "C") & (a2 == "G")) |
|
|
185
|
+
((a1 == "G") & (a2 == "C"))
|
|
186
|
+
)
|
|
187
|
+
n_before = self.sumstats.height
|
|
188
|
+
df = self.sumstats.filter(~is_palindrome)
|
|
189
|
+
n_after = df.height
|
|
190
|
+
print(f"Removed palindromic SNPs: {n_before - n_after}")
|
|
191
|
+
print(f"Rows after palindrome removal: {n_after}")
|
|
192
|
+
self.sumstats = df
|
|
193
|
+
return df
|
|
194
|
+
|
|
195
|
+
def _maf_filter(self):
|
|
196
|
+
if self.freq_col is None:
|
|
197
|
+
print("Skipping MAF filter: no frequency column supplied.")
|
|
198
|
+
return self.sumstats
|
|
199
|
+
|
|
200
|
+
# FRQ = A1 freq -> MAF = min(FRQ, 1 - FRQ)
|
|
201
|
+
n_before = self.sumstats.height
|
|
202
|
+
df = self.sumstats.filter(
|
|
203
|
+
pl.min_horizontal(pl.col(self.freq_col), 1 - pl.col(self.freq_col)) >= self.maf_thresh
|
|
204
|
+
)
|
|
205
|
+
n_after = df.height
|
|
206
|
+
print(f"Removed low-MAF variants: {n_before - n_after}")
|
|
207
|
+
print(f"Rows after MAF filter: {n_after}")
|
|
208
|
+
self.sumstats = df
|
|
209
|
+
return df
|
|
210
|
+
|
|
211
|
+
def _info_filter(self):
|
|
212
|
+
if self.info_col is None:
|
|
213
|
+
print("Skipping INFO filter: no INFO column supplied.")
|
|
214
|
+
return self.sumstats
|
|
215
|
+
|
|
216
|
+
n_before = self.sumstats.height
|
|
217
|
+
df = self.sumstats.filter(
|
|
218
|
+
pl.col(self.info_col) >= self.info_thresh
|
|
219
|
+
)
|
|
220
|
+
n_after = df.height
|
|
221
|
+
print(f"Removed low-INFO variants: {n_before - n_after}")
|
|
222
|
+
print(f"Rows after INFO filter: {n_after}")
|
|
223
|
+
self.sumstats = df
|
|
224
|
+
return df
|
|
225
|
+
|
|
226
|
+
def _impute_effective_n(self):
|
|
227
|
+
# per-SNP N given -> keep it (_standardise_shape renames n_col -> N)
|
|
228
|
+
if self.n_col is not None:
|
|
229
|
+
print(f"Using per-SNP sample size from column: {self.n_col}")
|
|
230
|
+
return self.sumstats
|
|
231
|
+
|
|
232
|
+
# depends on whether type == cc / quant
|
|
233
|
+
if self.trait_type == "quant":
|
|
234
|
+
if self.n is None:
|
|
235
|
+
raise ValueError("trait_type 'quant' needs n_col or n")
|
|
236
|
+
n_eff = int(round(self.n))
|
|
237
|
+
elif self.trait_type == "cc":
|
|
238
|
+
if self.n_cases is None or self.n_controls is None:
|
|
239
|
+
raise ValueError("trait_type 'cc' needs n_col or n_cases + n_controls")
|
|
240
|
+
n_eff = int(round(4 * self.n_cases * self.n_controls / (self.n_cases + self.n_controls)))
|
|
241
|
+
else:
|
|
242
|
+
raise ValueError(f"Unsupported trait_type: {self.trait_type}")
|
|
243
|
+
|
|
244
|
+
df = self.sumstats.with_columns(pl.lit(n_eff, dtype=pl.Int64).alias("N"))
|
|
245
|
+
print(f"N set to: {n_eff} ({self.trait_type})")
|
|
246
|
+
self.sumstats = df
|
|
247
|
+
return df
|
|
248
|
+
|
|
249
|
+
def _liftover(self):
|
|
250
|
+
# same build -> nothing to do
|
|
251
|
+
if self.genome_build == self.target_genome_build:
|
|
252
|
+
print(f"Skipping liftover: already {self.target_genome_build}")
|
|
253
|
+
return self.sumstats
|
|
254
|
+
|
|
255
|
+
converter = ChainFile(self.chain_file, one_based=True)
|
|
256
|
+
chrs = self.sumstats[self.chr_col].to_list()
|
|
257
|
+
poss = self.sumstats[self.pos_col].to_list()
|
|
258
|
+
lifted_chrs = []
|
|
259
|
+
lifted_poss = []
|
|
260
|
+
|
|
261
|
+
for chrom, pos in zip(chrs, poss):
|
|
262
|
+
hits = converter[f"chr{chrom}"][int(pos)]
|
|
263
|
+
|
|
264
|
+
# no hit, or lands on another chr / alt contig -> drop
|
|
265
|
+
if len(hits) == 0 or hits[0][0].replace("chr", "") != chrom:
|
|
266
|
+
lifted_chrs.append(None)
|
|
267
|
+
lifted_poss.append(None)
|
|
268
|
+
else:
|
|
269
|
+
lifted_chrs.append(chrom)
|
|
270
|
+
lifted_poss.append(hits[0][1])
|
|
271
|
+
|
|
272
|
+
df = self.sumstats.with_columns([
|
|
273
|
+
pl.Series("CHR_LIFTED", lifted_chrs),
|
|
274
|
+
pl.Series("POS_LIFTED", lifted_poss),
|
|
275
|
+
])
|
|
276
|
+
|
|
277
|
+
n_before = df.height
|
|
278
|
+
df = df.drop_nulls(["CHR_LIFTED", "POS_LIFTED"])
|
|
279
|
+
n_after = df.height
|
|
280
|
+
print(f"Removed failed liftover variants: {n_before - n_after}")
|
|
281
|
+
print(f"Rows after liftover: {n_after}")
|
|
282
|
+
df = df.with_columns([
|
|
283
|
+
pl.col("CHR_LIFTED").alias(self.chr_col),
|
|
284
|
+
pl.col("POS_LIFTED").cast(pl.Int64).alias(self.pos_col),
|
|
285
|
+
])
|
|
286
|
+
df = df.drop(["CHR_LIFTED", "POS_LIFTED"])
|
|
287
|
+
self.sumstats = df
|
|
288
|
+
return df
|
|
289
|
+
|
|
290
|
+
# --- --- --- --- --- --- --- --- --- --- --- --- ---
|
|
291
|
+
# --- These bottom ones -> after _liftover() functs
|
|
292
|
+
# --- --- --- --- --- --- --- --- --- --- --- --- ---
|
|
293
|
+
|
|
294
|
+
def _align_betas_to_risk_allele(self):
|
|
295
|
+
# align to risk allele: BETA < 0 -> flip sign + swap A1/A2 (+ FRQ -> 1 - FRQ)
|
|
296
|
+
flip = pl.col(self.beta_col) < 0
|
|
297
|
+
n_flip = self.sumstats.filter(flip).height
|
|
298
|
+
|
|
299
|
+
cols = [
|
|
300
|
+
pl.when(flip).then(-pl.col(self.beta_col)).otherwise(pl.col(self.beta_col)).alias(self.beta_col),
|
|
301
|
+
pl.when(flip).then(pl.col(self.a2_col)).otherwise(pl.col(self.a1_col)).alias(self.a1_col),
|
|
302
|
+
pl.when(flip).then(pl.col(self.a1_col)).otherwise(pl.col(self.a2_col)).alias(self.a2_col),
|
|
303
|
+
]
|
|
304
|
+
if self.freq_col is not None:
|
|
305
|
+
cols.append(
|
|
306
|
+
pl.when(flip).then(1 - pl.col(self.freq_col)).otherwise(pl.col(self.freq_col)).alias(self.freq_col)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
df = self.sumstats.with_columns(cols)
|
|
310
|
+
print(f"Flipped to risk allele: {n_flip}")
|
|
311
|
+
self.sumstats = df
|
|
312
|
+
return df
|
|
313
|
+
|
|
314
|
+
# removing specific regions
|
|
315
|
+
def _remove_mhc(self):
|
|
316
|
+
|
|
317
|
+
# to run after liftover
|
|
318
|
+
if not self.drop_mhc:
|
|
319
|
+
print("Keeping MHC region!")
|
|
320
|
+
return self.sumstats
|
|
321
|
+
|
|
322
|
+
if self.target_genome_build == "GRCh38":
|
|
323
|
+
mhc_start = 24999772
|
|
324
|
+
mhc_end = 34032223
|
|
325
|
+
elif self.target_genome_build == "GRCh37":
|
|
326
|
+
mhc_start = 25000000
|
|
327
|
+
mhc_end = 34000000
|
|
328
|
+
else:
|
|
329
|
+
raise ValueError(f"Unsupported target_genome_build: {self.target_genome_build}")
|
|
330
|
+
|
|
331
|
+
n_before = self.sumstats.height
|
|
332
|
+
df = self.sumstats.filter(
|
|
333
|
+
~(
|
|
334
|
+
(pl.col(self.chr_col) == "6") &
|
|
335
|
+
(pl.col(self.pos_col) >= mhc_start) &
|
|
336
|
+
(pl.col(self.pos_col) <= mhc_end)
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
n_after = df.height
|
|
340
|
+
print(f"Removed MHC variants: {n_before - n_after} ({self.target_genome_build})!")
|
|
341
|
+
self.sumstats = df
|
|
342
|
+
return df
|
|
343
|
+
|
|
344
|
+
def _remove_apoe(self):
|
|
345
|
+
|
|
346
|
+
if not self.drop_apoe:
|
|
347
|
+
print("Keeping APOE region!")
|
|
348
|
+
return self.sumstats
|
|
349
|
+
|
|
350
|
+
if self.target_genome_build == "GRCh38":
|
|
351
|
+
apoe_start = 43495848
|
|
352
|
+
apoe_end = 45996742
|
|
353
|
+
elif self.target_genome_build == "GRCh37":
|
|
354
|
+
apoe_start = 44000000
|
|
355
|
+
apoe_end = 46500000
|
|
356
|
+
else:
|
|
357
|
+
raise ValueError(f"Unsupported target_genome_build: {self.target_genome_build}")
|
|
358
|
+
|
|
359
|
+
n_before = self.sumstats.height
|
|
360
|
+
df = self.sumstats.filter(
|
|
361
|
+
~(
|
|
362
|
+
(pl.col(self.chr_col) == "19") &
|
|
363
|
+
(pl.col(self.pos_col) >= apoe_start) &
|
|
364
|
+
(pl.col(self.pos_col) <= apoe_end)
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
n_after = df.height
|
|
368
|
+
print(f"Removed APOE variants: {n_before - n_after} ({self.target_genome_build})!")
|
|
369
|
+
self.sumstats = df
|
|
370
|
+
return df
|
|
371
|
+
|
|
372
|
+
def _standardise_shape(self):
|
|
373
|
+
# rename cols to the sherlock format (A1 = effect allele)
|
|
374
|
+
cols = [
|
|
375
|
+
pl.col(self.snp_col).alias("SNP"),
|
|
376
|
+
pl.col(self.chr_col).alias("CHR"),
|
|
377
|
+
pl.col(self.pos_col).alias("BP"),
|
|
378
|
+
pl.col(self.a1_col).alias("A1"),
|
|
379
|
+
pl.col(self.a2_col).alias("A2"),
|
|
380
|
+
pl.col(self.beta_col).alias("BETA"),
|
|
381
|
+
pl.col(self.se_col).alias("SE"),
|
|
382
|
+
pl.col(self.pval_col).alias("P"),
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
# sample size: always one N column
|
|
386
|
+
if self.n_col is not None:
|
|
387
|
+
cols.append(pl.col(self.n_col).alias("N"))
|
|
388
|
+
elif "N" in self.sumstats.columns:
|
|
389
|
+
cols.append(pl.col("N"))
|
|
390
|
+
|
|
391
|
+
# optional cols if given
|
|
392
|
+
if self.freq_col is not None:
|
|
393
|
+
cols.append(pl.col(self.freq_col).alias("FRQ"))
|
|
394
|
+
if self.info_col is not None:
|
|
395
|
+
cols.append(pl.col(self.info_col).alias("INFO"))
|
|
396
|
+
|
|
397
|
+
df = self.sumstats.select(cols)
|
|
398
|
+
self.sumstats = df
|
|
399
|
+
return df
|
|
400
|
+
|
|
401
|
+
# running wrapper for class -> thin wrapper in bin/ & nf module/subworkflow later!!!!
|
|
402
|
+
def run(self):
|
|
403
|
+
print(f"Rows at start: {self.sumstats.height}")
|
|
404
|
+
self._fix_data_types()
|
|
405
|
+
self._correct_for_missingness()
|
|
406
|
+
self._fix_pval()
|
|
407
|
+
self._remove_indels()
|
|
408
|
+
self._remove_palindromes()
|
|
409
|
+
self._remove_duplicates()
|
|
410
|
+
self._remove_multiallelic()
|
|
411
|
+
self._restrict_to_autosomal()
|
|
412
|
+
self._maf_filter()
|
|
413
|
+
self._info_filter()
|
|
414
|
+
self._liftover()
|
|
415
|
+
self._remove_multiallelic()
|
|
416
|
+
self._remove_mhc()
|
|
417
|
+
self._remove_apoe()
|
|
418
|
+
self._align_betas_to_risk_allele()
|
|
419
|
+
self._impute_effective_n()
|
|
420
|
+
self._standardise_shape()
|
|
421
|
+
print(f"Rows at end: {self.sumstats.height}")
|
|
422
|
+
return self.sumstats
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
|
|
3
|
+
from sherlock_tools.preprocessing import QualityControl
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# python3.12 -m pytest -q
|
|
9
|
+
# First bloody unit test
|
|
10
|
+
# generate toy df and dat
|
|
11
|
+
# one function to test each thing
|
|
12
|
+
# final funct to run
|
|
13
|
+
|
|
14
|
+
COLS = ["MarkerName", "Chrom", "Pos", "EA", "NEA", "Beta", "StdErr", "Pval", "Freq", "Info"]
|
|
15
|
+
|
|
16
|
+
ROWS = [
|
|
17
|
+
# SNP CHR POS A1 A2 BETA SE P FRQ INFO
|
|
18
|
+
("rs1", "1", "1000", "a", "g", "0.1", "0.01", "1e-5", "0.30", "0.95"), # keep (lowercase alleles)
|
|
19
|
+
("rs2", "chr2", "2000", "A", "C", "-0.2", "0.02", "0", "0.40", "0.90"), # keep (chr prefix, P == 0, flip)
|
|
20
|
+
("rs3", "3", "3000", "AT", "G", "0.1", "0.01", "0.5", "0.30", "0.95"), # indel
|
|
21
|
+
("rs4", "4", "4000", "A", "T", "0.1", "0.01", "0.5", "0.30", "0.95"), # palindrome
|
|
22
|
+
("rs5", "5", "5000", "A", "G", "0.1", "0.01", "0.5", "0.30", "0.95"), # duplicate ID
|
|
23
|
+
("rs5", "5", "5001", "A", "G", "0.1", "0.01", "0.5", "0.30", "0.95"), # duplicate ID
|
|
24
|
+
("rs6", "7", "7000", "A", "G", "0.1", "0.01", "0.5", "0.30", "0.95"), # multiallelic
|
|
25
|
+
("rs7", "7", "7000", "A", "C", "0.1", "0.01", "0.5", "0.30", "0.95"), # multiallelic
|
|
26
|
+
("rs8", "X", "8000", "A", "G", "0.1", "0.01", "0.5", "0.30", "0.95"), # non-autosomal
|
|
27
|
+
("rs9", "9", "9000", "A", "G", "0.1", "0.01", "0.5", "0.005", "0.95"), # low MAF
|
|
28
|
+
("rs10", "10", "10000", "A", "G", "0.1", "0.01", "0.5", "0.995", "0.95"), # low MAF (FRQ > 0.5 side)
|
|
29
|
+
("rs11", "11", "11000", "A", "G", "0.1", "0.01", "0.5", "0.30", "0.50"), # low INFO
|
|
30
|
+
("rs12", "12", "12000", "A", "G", None, "0.01", "0.5", "0.30", "0.95"), # missing BETA
|
|
31
|
+
("rs13", "6", "30000000", "A", "G", "0.1", "0.01", "0.5", "0.30", "0.95"), # MHC (GRCh38)
|
|
32
|
+
("rs14", "19", "44908684", "C", "T", "0.1", "0.01", "0.5", "0.30", "0.95"), # APOE (GRCh38)
|
|
33
|
+
("rs15", "15", "15000", "C", "T", "0.1", "0.01", "0.5", "0.30", "0.95"), # keep
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def toy_df():
|
|
38
|
+
return pl.DataFrame(ROWS, schema=COLS, orient="row")
|
|
39
|
+
|
|
40
|
+
# print(toy_df())
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def make_qc(toy_df):
|
|
43
|
+
# factory -> each test can override any arg
|
|
44
|
+
def _make(**kwargs):
|
|
45
|
+
args = dict(
|
|
46
|
+
sumstats=toy_df,
|
|
47
|
+
snp_col="MarkerName",
|
|
48
|
+
chr_col="Chrom",
|
|
49
|
+
pos_col="Pos",
|
|
50
|
+
a1_col="EA",
|
|
51
|
+
a2_col="NEA",
|
|
52
|
+
beta_col="Beta",
|
|
53
|
+
se_col="StdErr",
|
|
54
|
+
pval_col="Pval",
|
|
55
|
+
maf_thresh=0.01,
|
|
56
|
+
genome_build="GRCh38",
|
|
57
|
+
target_genome_build="GRCh38",
|
|
58
|
+
trait_type="cc",
|
|
59
|
+
n_cases=1000,
|
|
60
|
+
n_controls=3000,
|
|
61
|
+
freq_col="Freq",
|
|
62
|
+
info_col="Info",
|
|
63
|
+
info_thresh=0.8,
|
|
64
|
+
drop_mhc=True,
|
|
65
|
+
drop_apoe=True,
|
|
66
|
+
)
|
|
67
|
+
args.update(kwargs)
|
|
68
|
+
return QualityControl(**args)
|
|
69
|
+
return _make
|
|
70
|
+
|
|
71
|
+
def removed(before, after, col="MarkerName"):
|
|
72
|
+
return set(before[col].to_list()) - set(after[col].to_list())
|
|
73
|
+
|
|
74
|
+
# one test per method!
|
|
75
|
+
def test_fix_data_types(make_qc):
|
|
76
|
+
qc = make_qc()
|
|
77
|
+
df = qc._fix_data_types()
|
|
78
|
+
assert df["Pos"].dtype == pl.Int64
|
|
79
|
+
assert df["Beta"].dtype == pl.Float64
|
|
80
|
+
assert df["Freq"].dtype == pl.Float64
|
|
81
|
+
assert df.filter(pl.col("MarkerName") == "rs2")["Chrom"].item() == "2"
|
|
82
|
+
assert df.filter(pl.col("MarkerName") == "rs1")["EA"].item() == "A"
|
|
83
|
+
|
|
84
|
+
def test_fix_pval(make_qc):
|
|
85
|
+
qc = make_qc()
|
|
86
|
+
qc._fix_data_types()
|
|
87
|
+
df = qc._fix_pval()
|
|
88
|
+
assert df.filter(pl.col("MarkerName") == "rs2")["Pval"].item() == 1e-300
|
|
89
|
+
assert (df["Pval"] > 0).all()
|
|
90
|
+
|
|
91
|
+
def test_correct_for_missingness(make_qc):
|
|
92
|
+
qc = make_qc()
|
|
93
|
+
before = qc._fix_data_types()
|
|
94
|
+
after = qc._correct_for_missingness()
|
|
95
|
+
assert removed(before, after) == {"rs12"}
|
|
96
|
+
|
|
97
|
+
def test_restrict_to_autosomal(make_qc):
|
|
98
|
+
qc = make_qc()
|
|
99
|
+
before = qc._fix_data_types()
|
|
100
|
+
after = qc._restrict_to_autosomal()
|
|
101
|
+
assert removed(before, after) == {"rs8"}
|
|
102
|
+
|
|
103
|
+
def test_remove_duplicates(make_qc):
|
|
104
|
+
qc = make_qc()
|
|
105
|
+
before = qc._fix_data_types()
|
|
106
|
+
after = qc._remove_duplicates()
|
|
107
|
+
assert removed(before, after) == {"rs5"}
|
|
108
|
+
|
|
109
|
+
def test_remove_multiallelic(make_qc):
|
|
110
|
+
qc = make_qc()
|
|
111
|
+
before = qc._fix_data_types()
|
|
112
|
+
after = qc._remove_multiallelic()
|
|
113
|
+
assert removed(before, after) == {"rs6", "rs7"}
|
|
114
|
+
|
|
115
|
+
def test_remove_indels(make_qc):
|
|
116
|
+
qc = make_qc()
|
|
117
|
+
before = qc._fix_data_types()
|
|
118
|
+
after = qc._remove_indels()
|
|
119
|
+
assert removed(before, after) == {"rs3"}
|
|
120
|
+
|
|
121
|
+
def test_remove_palindromes(make_qc):
|
|
122
|
+
qc = make_qc()
|
|
123
|
+
before = qc._fix_data_types()
|
|
124
|
+
after = qc._remove_palindromes()
|
|
125
|
+
assert removed(before, after) == {"rs4"}
|
|
126
|
+
|
|
127
|
+
def test_maf_filter(make_qc):
|
|
128
|
+
qc = make_qc()
|
|
129
|
+
before = qc._fix_data_types()
|
|
130
|
+
after = qc._maf_filter()
|
|
131
|
+
assert removed(before, after) == {"rs9", "rs10"}
|
|
132
|
+
|
|
133
|
+
def test_maf_filter_skips_without_freq(make_qc):
|
|
134
|
+
qc = make_qc(freq_col=None)
|
|
135
|
+
before = qc._fix_data_types()
|
|
136
|
+
after = qc._maf_filter()
|
|
137
|
+
assert after.height == before.height
|
|
138
|
+
|
|
139
|
+
def test_info_filter(make_qc):
|
|
140
|
+
qc = make_qc()
|
|
141
|
+
before = qc._fix_data_types()
|
|
142
|
+
after = qc._info_filter()
|
|
143
|
+
assert removed(before, after) == {"rs11"}
|
|
144
|
+
|
|
145
|
+
def test_remove_mhc(make_qc):
|
|
146
|
+
qc = make_qc()
|
|
147
|
+
before = qc._fix_data_types()
|
|
148
|
+
after = qc._remove_mhc()
|
|
149
|
+
assert removed(before, after) == {"rs13"}
|
|
150
|
+
|
|
151
|
+
def test_keep_mhc(make_qc):
|
|
152
|
+
qc = make_qc(drop_mhc=False)
|
|
153
|
+
before = qc._fix_data_types()
|
|
154
|
+
after = qc._remove_mhc()
|
|
155
|
+
assert after.height == before.height
|
|
156
|
+
|
|
157
|
+
def test_remove_apoe(make_qc):
|
|
158
|
+
qc = make_qc()
|
|
159
|
+
before = qc._fix_data_types()
|
|
160
|
+
after = qc._remove_apoe()
|
|
161
|
+
assert removed(before, after) == {"rs14"}
|
|
162
|
+
|
|
163
|
+
def test_keep_apoe(make_qc):
|
|
164
|
+
qc = make_qc(drop_apoe=False)
|
|
165
|
+
before = qc._fix_data_types()
|
|
166
|
+
after = qc._remove_apoe()
|
|
167
|
+
assert after.height == before.height
|
|
168
|
+
|
|
169
|
+
def test_align_betas_to_risk_allele(make_qc):
|
|
170
|
+
qc = make_qc()
|
|
171
|
+
qc._fix_data_types()
|
|
172
|
+
df = qc._align_betas_to_risk_allele()
|
|
173
|
+
rs2 = df.filter(pl.col("MarkerName") == "rs2").row(0, named=True)
|
|
174
|
+
assert rs2["Beta"] == 0.2
|
|
175
|
+
assert (rs2["EA"], rs2["NEA"]) == ("C", "A")
|
|
176
|
+
assert rs2["Freq"] == pytest.approx(0.6)
|
|
177
|
+
assert (df["Beta"].drop_nulls() >= 0).all()
|
|
178
|
+
|
|
179
|
+
# sample size
|
|
180
|
+
def test_effective_n_cc(make_qc):
|
|
181
|
+
qc = make_qc()
|
|
182
|
+
df = qc._impute_effective_n()
|
|
183
|
+
assert (df["N"] == 3000).all() # 4 * 1000 * 3000 / 4000
|
|
184
|
+
|
|
185
|
+
def test_effective_n_quant(make_qc):
|
|
186
|
+
qc = make_qc(trait_type="quant", n=5000, n_cases=None, n_controls=None)
|
|
187
|
+
df = qc._impute_effective_n()
|
|
188
|
+
assert (df["N"] == 5000).all()
|
|
189
|
+
|
|
190
|
+
def test_effective_n_missing_raises(make_qc):
|
|
191
|
+
qc = make_qc(trait_type="quant", n=None)
|
|
192
|
+
with pytest.raises(ValueError):
|
|
193
|
+
qc._impute_effective_n()
|
|
194
|
+
|
|
195
|
+
# liftover (fake chain file -> no real file needed)
|
|
196
|
+
class FakeChainFile:
|
|
197
|
+
# chr1 -> +100 bp, chr2 -> no hit, chr15 -> lands on chr16
|
|
198
|
+
def __init__(self, path, one_based=True):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def __getitem__(self, chrom):
|
|
202
|
+
def lookup(pos):
|
|
203
|
+
if chrom == "chr1":
|
|
204
|
+
return [("chr1", pos + 100, "+")]
|
|
205
|
+
if chrom == "chr15":
|
|
206
|
+
return [("chr16", pos, "+")]
|
|
207
|
+
return []
|
|
208
|
+
return _Lookup(lookup)
|
|
209
|
+
|
|
210
|
+
class _Lookup:
|
|
211
|
+
def __init__(self, fn):
|
|
212
|
+
self.fn = fn
|
|
213
|
+
|
|
214
|
+
def __getitem__(self, pos):
|
|
215
|
+
return self.fn(pos)
|
|
216
|
+
|
|
217
|
+
def test_liftover_skips_same_build(make_qc):
|
|
218
|
+
qc = make_qc()
|
|
219
|
+
before = qc._fix_data_types()
|
|
220
|
+
after = qc._liftover()
|
|
221
|
+
assert after.equals(before)
|
|
222
|
+
|
|
223
|
+
def test_liftover(make_qc, monkeypatch):
|
|
224
|
+
monkeypatch.setattr("sherlock_tools.preprocessing.qc.ChainFile", FakeChainFile)
|
|
225
|
+
qc = make_qc(genome_build="GRCh37", chain_file="fake.chain")
|
|
226
|
+
qc._fix_data_types()
|
|
227
|
+
qc.sumstats = qc.sumstats.filter(pl.col("MarkerName").is_in(["rs1", "rs2", "rs15"]))
|
|
228
|
+
df = qc._liftover()
|
|
229
|
+
assert df["MarkerName"].to_list() == ["rs1"] # rs2 no hit, rs15 changed chr
|
|
230
|
+
assert df["Pos"].item() == 1100
|
|
231
|
+
|
|
232
|
+
# full run
|
|
233
|
+
def test_run(make_qc):
|
|
234
|
+
df = make_qc().run()
|
|
235
|
+
assert df.columns == ["SNP", "CHR", "BP", "A1", "A2", "BETA", "SE", "P", "N", "FRQ", "INFO"]
|
|
236
|
+
assert set(df["SNP"].to_list()) == {"rs1", "rs2", "rs15"}
|
|
237
|
+
assert (df["BETA"] >= 0).all()
|
|
238
|
+
assert (df["P"] > 0).all()
|
|
239
|
+
assert (df["N"] == 3000).all()
|