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.
@@ -0,0 +1,13 @@
1
+ .nextflow*
2
+ work/
3
+ data/
4
+ results/
5
+ .DS_Store
6
+ testing/
7
+ dist/
8
+ testing*
9
+ *.pyc
10
+ null/
11
+ .lineage/
12
+ .nf-test/
13
+ .nf-test.log
@@ -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,10 @@
1
+ # sherlock-tools
2
+
3
+ Python library used by the nf-core/sherlock pipeline. Scripts in the pipeline's `bin/` folder import it.
4
+
5
+ ## Development install
6
+
7
+ ```bash
8
+ pip install -e "sherlock-tools[dev]"
9
+ pytest sherlock-tools/tests
10
+ ```
@@ -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,3 @@
1
+ from .qc import QualityControl
2
+
3
+ __all__ = ["QualityControl"]
@@ -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()