sherlock-tools 0.1.0__py3-none-any.whl

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.
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,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,6 @@
1
+ sherlock_tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ sherlock_tools/preprocessing/__init__.py,sha256=tx_PQVBoK7cc-K3spXqBteQIHhXFW2zY-htKITFrMm0,61
3
+ sherlock_tools/preprocessing/qc.py,sha256=diTIyHFSjyGYqLt-mejC_PkKhmAuouq8QXzOBfgaHUg,15450
4
+ sherlock_tools-0.1.0.dist-info/METADATA,sha256=tcdtOEAatRqF0vHwAW4ogJYmGdcw2cToKBC5tRunvL4,1167
5
+ sherlock_tools-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ sherlock_tools-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any