localqtl 0.1.1__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.
- localqtl-0.1.1/PKG-INFO +221 -0
- localqtl-0.1.1/README.md +186 -0
- localqtl-0.1.1/pyproject.toml +51 -0
- localqtl-0.1.1/src/localqtl/__init__.py +50 -0
- localqtl-0.1.1/src/localqtl/_torch_utils.py +44 -0
- localqtl-0.1.1/src/localqtl/cis/__init__.py +11 -0
- localqtl-0.1.1/src/localqtl/cis/common.py +124 -0
- localqtl-0.1.1/src/localqtl/cis/independent.py +675 -0
- localqtl-0.1.1/src/localqtl/cis/mapper.py +157 -0
- localqtl-0.1.1/src/localqtl/cis/nominal.py +424 -0
- localqtl-0.1.1/src/localqtl/cis/permutations.py +375 -0
- localqtl-0.1.1/src/localqtl/cis/postproc.py +159 -0
- localqtl-0.1.1/src/localqtl/finemap.py +153 -0
- localqtl-0.1.1/src/localqtl/genotypeio.py +402 -0
- localqtl-0.1.1/src/localqtl/haplotypeio.py +417 -0
- localqtl-0.1.1/src/localqtl/iosinks.py +304 -0
- localqtl-0.1.1/src/localqtl/pgen.py +211 -0
- localqtl-0.1.1/src/localqtl/phenotypeio.py +101 -0
- localqtl-0.1.1/src/localqtl/preproc.py +78 -0
- localqtl-0.1.1/src/localqtl/regression_kernels.py +258 -0
- localqtl-0.1.1/src/localqtl/stats.py +217 -0
- localqtl-0.1.1/src/localqtl/utils.py +87 -0
localqtl-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: localqtl
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: GPU-accelerated ancestry-aware cis-QTL mapping library with tensorQTL-compatible APIs
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Keywords: genomics,gpu,xQTL,qtl
|
|
7
|
+
Author: Kynon J Benjamin
|
|
8
|
+
Author-email: kj.benjamin90@gmail.com
|
|
9
|
+
Maintainer: Kynon J Benjamin
|
|
10
|
+
Maintainer-email: kj.benjamin90@gmail.com
|
|
11
|
+
Requires-Python: >=3.11,<3.15
|
|
12
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: cudf-cu12 (>=25.10,<26.0)
|
|
19
|
+
Requires-Dist: cupy-cuda12x (>=13.6,<14.0)
|
|
20
|
+
Requires-Dist: dask-cudf-cu12 (>=25.10.0,<26.0.0)
|
|
21
|
+
Requires-Dist: numpy (>=2.0,<3.0)
|
|
22
|
+
Requires-Dist: pandas (>=2.2,<3.0)
|
|
23
|
+
Requires-Dist: pandas-plink-ng (>=2.3.2.post1,<3.0.0)
|
|
24
|
+
Requires-Dist: pgenlib (>=0.93,<0.94)
|
|
25
|
+
Requires-Dist: py-qvalue (>=0.1,<0.2)
|
|
26
|
+
Requires-Dist: pyarrow (>=22.0,<23.0)
|
|
27
|
+
Requires-Dist: rfmix-reader (>=0.2,<0.3)
|
|
28
|
+
Requires-Dist: scipy (>=1.16,<2.0)
|
|
29
|
+
Requires-Dist: torch (>=2.8,<3.0)
|
|
30
|
+
Project-URL: Bug Tracker, https://github.com/heart-gen/localQTL/issues
|
|
31
|
+
Project-URL: homepage, https://github.com/heart-gen/localQTL
|
|
32
|
+
Project-URL: repository, https://github.com/heart-gen/localQTL
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# localQTL
|
|
36
|
+
|
|
37
|
+
localQTL is a **pure-Python** library for local-ancestry-aware xQTL mapping that
|
|
38
|
+
lets researchers run end-to-end analyses on large cohorts **without any R/rpy2
|
|
39
|
+
dependencies**. It preserves the familiar
|
|
40
|
+
[tensorQTL](https://github.com/broadinstitute/tensorqtl) data model with **GPU-first
|
|
41
|
+
execution paths**, flexible genotype loaders, and streaming outputs for large-scale
|
|
42
|
+
workflows, while adding ancestry-aware use cases.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **GPU-accelerated workflows** powered by PyTorch, with automatic fallbacks to CPU
|
|
47
|
+
execution when CUDA is unavailable.
|
|
48
|
+
- **Modular cis-QTL mapping API** exposed through functional helpers such as
|
|
49
|
+
`map_nominal`, `map_permutations`, and `map_independent`, or via the convenience
|
|
50
|
+
wrapper `CisMapper`.
|
|
51
|
+
- **Multiple genotype backends** including PLINK (BED/BIM/FAM), PLINK 2 (PGEN/PSAM/PVAR),
|
|
52
|
+
and BED/Parquet inputs, with helpers to stream data in manageable windows.
|
|
53
|
+
- **Local ancestry integration** by pairing genotype windows with haplotype panels
|
|
54
|
+
produced by RFMix through `RFMixReader` and `InputGeneratorCisWithHaps`.
|
|
55
|
+
- **Parquet streaming sinks** that make it easy to materialise association statistics
|
|
56
|
+
without loading the entire result set in memory.
|
|
57
|
+
- **Pure-Python statistics (no R/rpy2 required)**: tensorQTL's `rfunc` calls have been refactored to scipy.stats for p-values and q-values are computed with the Python port of Storey's `qvalue` (`py-qvalue`).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
The project uses [Poetry](https://python-poetry.org/) for dependency management.
|
|
62
|
+
Clone the repository and install the package into a virtual environment:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
poetry install
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you prefer `pip`, you can install the library in editable mode after exporting
|
|
69
|
+
Poetry's dependency specification:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **Note:** GPU acceleration relies on PyTorch, CuPy, and cuDF. Make sure you use
|
|
76
|
+
> versions that match the CUDA toolkit available on your system. The versions in
|
|
77
|
+
> `pyproject.toml` target CUDA 12.
|
|
78
|
+
|
|
79
|
+
## Quickstart
|
|
80
|
+
|
|
81
|
+
Below is a minimal example that runs a nominal cis-QTL scan against PLINK-formatted
|
|
82
|
+
genotypes and BED-formatted phenotypes. The example mirrors the data layout
|
|
83
|
+
expected by tensorQTL, so **existing preprocessing pipelines can be reused**.
|
|
84
|
+
|
|
85
|
+
### Standard mapping (tensorQTL equivalent)
|
|
86
|
+
```python
|
|
87
|
+
from localqtl import PlinkReader, read_phenotype_bed
|
|
88
|
+
from localqtl.cis import map_nominal
|
|
89
|
+
|
|
90
|
+
# Load genotypes and variant metadata
|
|
91
|
+
plink = PlinkReader("data/genotypes")
|
|
92
|
+
genotype_df = plink.load_genotypes()
|
|
93
|
+
variant_df = plink.bim.set_index("snp")[["chrom", "pos"]]
|
|
94
|
+
|
|
95
|
+
# Load phenotypes (BED-style) and their genomic coordinates
|
|
96
|
+
phenotype_df, phenotype_pos_df = read_phenotype_bed("data/phenotypes.bed")
|
|
97
|
+
|
|
98
|
+
# Optional: load covariates as a DataFrame indexed by sample IDs
|
|
99
|
+
covariates_df = None
|
|
100
|
+
|
|
101
|
+
results = map_nominal(
|
|
102
|
+
genotype_df=genotype_df,
|
|
103
|
+
variant_df=variant_df,
|
|
104
|
+
phenotype_df=phenotype_df,
|
|
105
|
+
phenotype_pos_df=phenotype_pos_df,
|
|
106
|
+
covariates_df=covariates_df,
|
|
107
|
+
window=1_000_000, # ±1 Mb cis window
|
|
108
|
+
maf_threshold=0.01, # filter on in-sample MAF
|
|
109
|
+
device="auto", # picks CUDA when available, otherwise CPU
|
|
110
|
+
out_prefix="cis_nominal", # default prefix
|
|
111
|
+
return_df=True # default is False, parquet streamed sink
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
print(results.head())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
For analyses that combine nominal scans, permutations, and independent signal
|
|
118
|
+
calling, the `CisMapper` class offers a thin object-oriented façade:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from localqtl.cis import CisMapper
|
|
122
|
+
|
|
123
|
+
mapper = CisMapper(
|
|
124
|
+
genotype_df=genotype_df,
|
|
125
|
+
variant_df=variant_df,
|
|
126
|
+
phenotype_df=phenotype_df,
|
|
127
|
+
phenotype_pos_df=phenotype_pos_df,
|
|
128
|
+
covariates_df=covariates_df,
|
|
129
|
+
window=500_000,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
mapper.map_nominal(nperm=1_000)
|
|
133
|
+
perm_df = mapper.map_permutations(nperm=1_000, beta_approx=True)
|
|
134
|
+
perm_df = mapper.calculate_qvalues(perm_df, fdr=0.05)
|
|
135
|
+
lead_df = mapper.map_independent(cis_df=perm_df, fdr=0.05)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Local ancestry-aware mapping
|
|
139
|
+
|
|
140
|
+
localQTL can incorporate local ancestry (e.g., from RFMix) so that cis-xQTL tests are performed with ancestry-aware genotype inputs. To enable this, pass **`haplotypes`** (per-variant × per-sample × per-ancestry dosages) and **`loci_df`** (variant positions corresponding to the haplotype tensor) to `CisMapper`. When these are provided, `CisMapper` switches to the ancestry-aware input generator under the hood.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from localqtl import PlinkReader, read_phenotype_bed
|
|
144
|
+
from localqtl.haplotypeio import RFMixReader
|
|
145
|
+
from localqtl.cis import CisMapper
|
|
146
|
+
|
|
147
|
+
plink = PlinkReader("data/genotypes")
|
|
148
|
+
genotype_df = plink.load_genotypes() # samples as columns, variants as rows
|
|
149
|
+
variant_df = plink.bim.set_index("snp")[["chrom", "pos"]]
|
|
150
|
+
|
|
151
|
+
phenotype_df, phenotype_pos_df = read_phenotype_bed("data/phenotypes.bed")
|
|
152
|
+
covariates_df = None # optional
|
|
153
|
+
|
|
154
|
+
# Local ancestry from RFMix
|
|
155
|
+
rfmix = RFMixReader(
|
|
156
|
+
prefix_path="data/rfmix/prefix", # directory with per-chrom outputs + fb.tsv
|
|
157
|
+
binary_path="data/rfmix", # where prebuilt binaries live (if used)
|
|
158
|
+
verbose=True
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Materialize ancestry haplotypes into memory (NumPy array)
|
|
162
|
+
# Shape: (variants, samples, ancestries) for >2 ancestries.
|
|
163
|
+
# For 2 ancestries, the reader exposes the ancestry channel selected internally.
|
|
164
|
+
H = rfmix.load_haplotypes()
|
|
165
|
+
loci_df = rfmix.loci_df # DataFrame with ['chrom','pos', 'ancestry', 'hap', 'index'] (indexed by 'hap')
|
|
166
|
+
|
|
167
|
+
# Align samples to the RFMix order (recommended)
|
|
168
|
+
# Keep only intersection and put everything in the same order as rfmix.sample_ids.
|
|
169
|
+
keep = [sid for sid in rfmix.sample_ids if sid in genotype_df.columns]
|
|
170
|
+
genotype_df = genotype_df[keep]
|
|
171
|
+
phenotype_df = phenotype_df[keep]
|
|
172
|
+
if covariates_df is not None:
|
|
173
|
+
covariates_df = covariates_df.loc[keep]
|
|
174
|
+
|
|
175
|
+
# (Optional) Ensure chromosome dtype matches between variant_df and loci_df
|
|
176
|
+
variant_df["chrom"] = variant_df["chrom"].astype(str)
|
|
177
|
+
loci_df = loci_df.copy()
|
|
178
|
+
loci_df["chrom"] = loci_df["chrom"].astype(str)
|
|
179
|
+
|
|
180
|
+
# Ancestry-aware mapping
|
|
181
|
+
mapper = CisMapper(
|
|
182
|
+
genotype_df=genotype_df,
|
|
183
|
+
variant_df=variant_df,
|
|
184
|
+
phenotype_df=phenotype_df,
|
|
185
|
+
phenotype_pos_df=phenotype_pos_df,
|
|
186
|
+
covariates_df=covariates_df,
|
|
187
|
+
haplotypes=H, # <-- enable local ancestry-aware mode
|
|
188
|
+
loci_df=loci_df, # <-- positions that align to H
|
|
189
|
+
window=1_000_000,
|
|
190
|
+
device="auto",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Run nominal scans and permutations as usual; ancestry-awareness is automatic
|
|
194
|
+
mapper.map_nominal(nperm=0)
|
|
195
|
+
perm_df = mapper.map_permutations(nperm=1_000, beta_approx=True)
|
|
196
|
+
|
|
197
|
+
# FDR using the pure-Python q-value port (no R/rpy2 needed)
|
|
198
|
+
perm_df = mapper.calculate_qvalues(perm_df, fdr=0.05)
|
|
199
|
+
|
|
200
|
+
# Identify conditionally independent signals
|
|
201
|
+
lead_df = mapper.map_independent(cis_df=perm_df, fdr=0.05)
|
|
202
|
+
|
|
203
|
+
print(lead_df.head())
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Input expectations**
|
|
207
|
+
|
|
208
|
+
* `H` (from `RFMixReader.load_haplotypes()`) is ancestry dosages with shape `(variants, samples, ancestries)` for ≥3 ancestries; for 2 ancestries the reader exposes the configured channel.
|
|
209
|
+
* `loci_df` rows correspond 1:1 to `H`'s and be joinable (by `chrom`/`pos`) to `variant_df` used for genotypes.
|
|
210
|
+
* Sample order in `genotype_df`, `phenotype_df`, and `covariates_df` should match `rfmix.sample_ids` (reindex as shown).
|
|
211
|
+
|
|
212
|
+
## Testing
|
|
213
|
+
|
|
214
|
+
Run the test suite with:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
poetry run pytest
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
This exercises the core cis-QTL mapping routines using small synthetic datasets.
|
|
221
|
+
|
localqtl-0.1.1/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# localQTL
|
|
2
|
+
|
|
3
|
+
localQTL is a **pure-Python** library for local-ancestry-aware xQTL mapping that
|
|
4
|
+
lets researchers run end-to-end analyses on large cohorts **without any R/rpy2
|
|
5
|
+
dependencies**. It preserves the familiar
|
|
6
|
+
[tensorQTL](https://github.com/broadinstitute/tensorqtl) data model with **GPU-first
|
|
7
|
+
execution paths**, flexible genotype loaders, and streaming outputs for large-scale
|
|
8
|
+
workflows, while adding ancestry-aware use cases.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **GPU-accelerated workflows** powered by PyTorch, with automatic fallbacks to CPU
|
|
13
|
+
execution when CUDA is unavailable.
|
|
14
|
+
- **Modular cis-QTL mapping API** exposed through functional helpers such as
|
|
15
|
+
`map_nominal`, `map_permutations`, and `map_independent`, or via the convenience
|
|
16
|
+
wrapper `CisMapper`.
|
|
17
|
+
- **Multiple genotype backends** including PLINK (BED/BIM/FAM), PLINK 2 (PGEN/PSAM/PVAR),
|
|
18
|
+
and BED/Parquet inputs, with helpers to stream data in manageable windows.
|
|
19
|
+
- **Local ancestry integration** by pairing genotype windows with haplotype panels
|
|
20
|
+
produced by RFMix through `RFMixReader` and `InputGeneratorCisWithHaps`.
|
|
21
|
+
- **Parquet streaming sinks** that make it easy to materialise association statistics
|
|
22
|
+
without loading the entire result set in memory.
|
|
23
|
+
- **Pure-Python statistics (no R/rpy2 required)**: tensorQTL's `rfunc` calls have been refactored to scipy.stats for p-values and q-values are computed with the Python port of Storey's `qvalue` (`py-qvalue`).
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
The project uses [Poetry](https://python-poetry.org/) for dependency management.
|
|
28
|
+
Clone the repository and install the package into a virtual environment:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
poetry install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If you prefer `pip`, you can install the library in editable mode after exporting
|
|
35
|
+
Poetry's dependency specification:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install -e .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> **Note:** GPU acceleration relies on PyTorch, CuPy, and cuDF. Make sure you use
|
|
42
|
+
> versions that match the CUDA toolkit available on your system. The versions in
|
|
43
|
+
> `pyproject.toml` target CUDA 12.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
Below is a minimal example that runs a nominal cis-QTL scan against PLINK-formatted
|
|
48
|
+
genotypes and BED-formatted phenotypes. The example mirrors the data layout
|
|
49
|
+
expected by tensorQTL, so **existing preprocessing pipelines can be reused**.
|
|
50
|
+
|
|
51
|
+
### Standard mapping (tensorQTL equivalent)
|
|
52
|
+
```python
|
|
53
|
+
from localqtl import PlinkReader, read_phenotype_bed
|
|
54
|
+
from localqtl.cis import map_nominal
|
|
55
|
+
|
|
56
|
+
# Load genotypes and variant metadata
|
|
57
|
+
plink = PlinkReader("data/genotypes")
|
|
58
|
+
genotype_df = plink.load_genotypes()
|
|
59
|
+
variant_df = plink.bim.set_index("snp")[["chrom", "pos"]]
|
|
60
|
+
|
|
61
|
+
# Load phenotypes (BED-style) and their genomic coordinates
|
|
62
|
+
phenotype_df, phenotype_pos_df = read_phenotype_bed("data/phenotypes.bed")
|
|
63
|
+
|
|
64
|
+
# Optional: load covariates as a DataFrame indexed by sample IDs
|
|
65
|
+
covariates_df = None
|
|
66
|
+
|
|
67
|
+
results = map_nominal(
|
|
68
|
+
genotype_df=genotype_df,
|
|
69
|
+
variant_df=variant_df,
|
|
70
|
+
phenotype_df=phenotype_df,
|
|
71
|
+
phenotype_pos_df=phenotype_pos_df,
|
|
72
|
+
covariates_df=covariates_df,
|
|
73
|
+
window=1_000_000, # ±1 Mb cis window
|
|
74
|
+
maf_threshold=0.01, # filter on in-sample MAF
|
|
75
|
+
device="auto", # picks CUDA when available, otherwise CPU
|
|
76
|
+
out_prefix="cis_nominal", # default prefix
|
|
77
|
+
return_df=True # default is False, parquet streamed sink
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
print(results.head())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For analyses that combine nominal scans, permutations, and independent signal
|
|
84
|
+
calling, the `CisMapper` class offers a thin object-oriented façade:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from localqtl.cis import CisMapper
|
|
88
|
+
|
|
89
|
+
mapper = CisMapper(
|
|
90
|
+
genotype_df=genotype_df,
|
|
91
|
+
variant_df=variant_df,
|
|
92
|
+
phenotype_df=phenotype_df,
|
|
93
|
+
phenotype_pos_df=phenotype_pos_df,
|
|
94
|
+
covariates_df=covariates_df,
|
|
95
|
+
window=500_000,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
mapper.map_nominal(nperm=1_000)
|
|
99
|
+
perm_df = mapper.map_permutations(nperm=1_000, beta_approx=True)
|
|
100
|
+
perm_df = mapper.calculate_qvalues(perm_df, fdr=0.05)
|
|
101
|
+
lead_df = mapper.map_independent(cis_df=perm_df, fdr=0.05)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Local ancestry-aware mapping
|
|
105
|
+
|
|
106
|
+
localQTL can incorporate local ancestry (e.g., from RFMix) so that cis-xQTL tests are performed with ancestry-aware genotype inputs. To enable this, pass **`haplotypes`** (per-variant × per-sample × per-ancestry dosages) and **`loci_df`** (variant positions corresponding to the haplotype tensor) to `CisMapper`. When these are provided, `CisMapper` switches to the ancestry-aware input generator under the hood.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from localqtl import PlinkReader, read_phenotype_bed
|
|
110
|
+
from localqtl.haplotypeio import RFMixReader
|
|
111
|
+
from localqtl.cis import CisMapper
|
|
112
|
+
|
|
113
|
+
plink = PlinkReader("data/genotypes")
|
|
114
|
+
genotype_df = plink.load_genotypes() # samples as columns, variants as rows
|
|
115
|
+
variant_df = plink.bim.set_index("snp")[["chrom", "pos"]]
|
|
116
|
+
|
|
117
|
+
phenotype_df, phenotype_pos_df = read_phenotype_bed("data/phenotypes.bed")
|
|
118
|
+
covariates_df = None # optional
|
|
119
|
+
|
|
120
|
+
# Local ancestry from RFMix
|
|
121
|
+
rfmix = RFMixReader(
|
|
122
|
+
prefix_path="data/rfmix/prefix", # directory with per-chrom outputs + fb.tsv
|
|
123
|
+
binary_path="data/rfmix", # where prebuilt binaries live (if used)
|
|
124
|
+
verbose=True
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Materialize ancestry haplotypes into memory (NumPy array)
|
|
128
|
+
# Shape: (variants, samples, ancestries) for >2 ancestries.
|
|
129
|
+
# For 2 ancestries, the reader exposes the ancestry channel selected internally.
|
|
130
|
+
H = rfmix.load_haplotypes()
|
|
131
|
+
loci_df = rfmix.loci_df # DataFrame with ['chrom','pos', 'ancestry', 'hap', 'index'] (indexed by 'hap')
|
|
132
|
+
|
|
133
|
+
# Align samples to the RFMix order (recommended)
|
|
134
|
+
# Keep only intersection and put everything in the same order as rfmix.sample_ids.
|
|
135
|
+
keep = [sid for sid in rfmix.sample_ids if sid in genotype_df.columns]
|
|
136
|
+
genotype_df = genotype_df[keep]
|
|
137
|
+
phenotype_df = phenotype_df[keep]
|
|
138
|
+
if covariates_df is not None:
|
|
139
|
+
covariates_df = covariates_df.loc[keep]
|
|
140
|
+
|
|
141
|
+
# (Optional) Ensure chromosome dtype matches between variant_df and loci_df
|
|
142
|
+
variant_df["chrom"] = variant_df["chrom"].astype(str)
|
|
143
|
+
loci_df = loci_df.copy()
|
|
144
|
+
loci_df["chrom"] = loci_df["chrom"].astype(str)
|
|
145
|
+
|
|
146
|
+
# Ancestry-aware mapping
|
|
147
|
+
mapper = CisMapper(
|
|
148
|
+
genotype_df=genotype_df,
|
|
149
|
+
variant_df=variant_df,
|
|
150
|
+
phenotype_df=phenotype_df,
|
|
151
|
+
phenotype_pos_df=phenotype_pos_df,
|
|
152
|
+
covariates_df=covariates_df,
|
|
153
|
+
haplotypes=H, # <-- enable local ancestry-aware mode
|
|
154
|
+
loci_df=loci_df, # <-- positions that align to H
|
|
155
|
+
window=1_000_000,
|
|
156
|
+
device="auto",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Run nominal scans and permutations as usual; ancestry-awareness is automatic
|
|
160
|
+
mapper.map_nominal(nperm=0)
|
|
161
|
+
perm_df = mapper.map_permutations(nperm=1_000, beta_approx=True)
|
|
162
|
+
|
|
163
|
+
# FDR using the pure-Python q-value port (no R/rpy2 needed)
|
|
164
|
+
perm_df = mapper.calculate_qvalues(perm_df, fdr=0.05)
|
|
165
|
+
|
|
166
|
+
# Identify conditionally independent signals
|
|
167
|
+
lead_df = mapper.map_independent(cis_df=perm_df, fdr=0.05)
|
|
168
|
+
|
|
169
|
+
print(lead_df.head())
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Input expectations**
|
|
173
|
+
|
|
174
|
+
* `H` (from `RFMixReader.load_haplotypes()`) is ancestry dosages with shape `(variants, samples, ancestries)` for ≥3 ancestries; for 2 ancestries the reader exposes the configured channel.
|
|
175
|
+
* `loci_df` rows correspond 1:1 to `H`'s and be joinable (by `chrom`/`pos`) to `variant_df` used for genotypes.
|
|
176
|
+
* Sample order in `genotype_df`, `phenotype_df`, and `covariates_df` should match `rfmix.sample_ids` (reindex as shown).
|
|
177
|
+
|
|
178
|
+
## Testing
|
|
179
|
+
|
|
180
|
+
Run the test suite with:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
poetry run pytest
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
This exercises the core cis-QTL mapping routines using small synthetic datasets.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "localqtl"
|
|
3
|
+
version = "0.1.1" # placeholder, replaced dynamically
|
|
4
|
+
description = "GPU-accelerated ancestry-aware cis-QTL mapping library with tensorQTL-compatible APIs"
|
|
5
|
+
authors = ["Kynon J Benjamin <kj.benjamin90@gmail.com>"]
|
|
6
|
+
maintainers = ["Kynon J Benjamin <kj.benjamin90@gmail.com>"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = "GPL-3.0-or-later"
|
|
9
|
+
packages = [{include = "localqtl", from = "src"}]
|
|
10
|
+
keywords = [
|
|
11
|
+
"genomics", "gpu", "xQTL", "qtl"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = ">=3.11,<3.15"
|
|
16
|
+
torch = "^2.8"
|
|
17
|
+
numpy = "^2.0"
|
|
18
|
+
scipy = "^1.16"
|
|
19
|
+
pandas = "^2.2"
|
|
20
|
+
pgenlib = "^0.93"
|
|
21
|
+
rfmix-reader = "^0.2"
|
|
22
|
+
py-qvalue = "^0.1"
|
|
23
|
+
cupy-cuda12x = "^13.6"
|
|
24
|
+
cudf-cu12 = "^25.10"
|
|
25
|
+
pyarrow = "^22.0"
|
|
26
|
+
dask-cudf-cu12 = "^25.10.0"
|
|
27
|
+
pandas-plink-ng = "^2.3.2.post1"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.urls]
|
|
30
|
+
homepage = "https://github.com/heart-gen/localQTL"
|
|
31
|
+
repository = "https://github.com/heart-gen/localQTL"
|
|
32
|
+
"Bug Tracker" = "https://github.com/heart-gen/localQTL/issues"
|
|
33
|
+
|
|
34
|
+
[tool.poetry-dynamic-versioning]
|
|
35
|
+
enable = false
|
|
36
|
+
style = "pep440"
|
|
37
|
+
dirty = true
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
41
|
+
build-backend = "poetry.core.masonry.api"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest (>=8.4.2,<9.0.0)",
|
|
46
|
+
"ruff (>=0.14.2,<0.15.0)",
|
|
47
|
+
"black (>=25.9.0,<26.0.0)",
|
|
48
|
+
"mypy (>=1.18.2,<2.0.0)",
|
|
49
|
+
"pytest-cov (>=7.0.0,<8.0.0)",
|
|
50
|
+
"pytest-xdist (>=3.8.0,<4.0.0)"
|
|
51
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Top-level package exports for :mod:`localqtl`.
|
|
2
|
+
|
|
3
|
+
This module re-exports the most commonly used classes/functions so that they
|
|
4
|
+
remain accessible from ``localqtl`` after the project was reorganized into a
|
|
5
|
+
package with dedicated submodules (``cis``, ``preproc``, ``finemap`` …).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import cis
|
|
9
|
+
from . import finemap
|
|
10
|
+
from . import genotypeio
|
|
11
|
+
from . import haplotypeio
|
|
12
|
+
from . import iosinks
|
|
13
|
+
from . import pgen
|
|
14
|
+
from . import phenotypeio
|
|
15
|
+
from . import preproc
|
|
16
|
+
from . import regression_kernels
|
|
17
|
+
from . import stats
|
|
18
|
+
from . import utils
|
|
19
|
+
|
|
20
|
+
from .cis import CisMapper, map_independent, map_nominal, map_permutations
|
|
21
|
+
from .genotypeio import InputGeneratorCis, PlinkReader
|
|
22
|
+
from .haplotypeio import InputGeneratorCisWithHaps, RFMixReader
|
|
23
|
+
from .phenotypeio import read_phenotype_bed
|
|
24
|
+
from .pgen import PgenReader
|
|
25
|
+
from .utils import gpu_available
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"map_nominal",
|
|
29
|
+
"map_permutations",
|
|
30
|
+
"map_independent",
|
|
31
|
+
"CisMapper",
|
|
32
|
+
"read_phenotype_bed",
|
|
33
|
+
"gpu_available",
|
|
34
|
+
"PlinkReader",
|
|
35
|
+
"RFMixReader",
|
|
36
|
+
"PgenReader",
|
|
37
|
+
"InputGeneratorCis",
|
|
38
|
+
"InputGeneratorCisWithHaps",
|
|
39
|
+
"cis",
|
|
40
|
+
"finemap",
|
|
41
|
+
"phenotypeio",
|
|
42
|
+
"genotypeio",
|
|
43
|
+
"haplotypeio",
|
|
44
|
+
"iosinks",
|
|
45
|
+
"pgen",
|
|
46
|
+
"preproc",
|
|
47
|
+
"regression_kernels",
|
|
48
|
+
"stats",
|
|
49
|
+
"utils",
|
|
50
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
TensorLike = Union[torch.Tensor, torch.nn.Parameter]
|
|
8
|
+
DeviceLike = Union[str, torch.device]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_device(device: DeviceLike) -> torch.device:
|
|
12
|
+
"""Normalize device specifications to a :class:`torch.device`."""
|
|
13
|
+
return device if isinstance(device, torch.device) else torch.device(device)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def move_to_device(
|
|
17
|
+
tensor: TensorLike,
|
|
18
|
+
device: torch.device,
|
|
19
|
+
*,
|
|
20
|
+
non_blocking: Optional[bool] = None,
|
|
21
|
+
) -> torch.Tensor:
|
|
22
|
+
"""Move ``tensor`` to ``device`` avoiding redundant copies.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
tensor : torch.Tensor
|
|
27
|
+
Tensor to move.
|
|
28
|
+
device : torch.device
|
|
29
|
+
Destination device.
|
|
30
|
+
non_blocking : bool, optional
|
|
31
|
+
When ``True`` and ``device`` is CUDA, allow asynchronous copies if the
|
|
32
|
+
source tensor is in pinned memory. If ``None`` (default), the flag is
|
|
33
|
+
enabled automatically for CUDA destinations.
|
|
34
|
+
"""
|
|
35
|
+
if tensor.device == device:
|
|
36
|
+
return tensor
|
|
37
|
+
|
|
38
|
+
if non_blocking is None:
|
|
39
|
+
non_blocking = device.type == "cuda"
|
|
40
|
+
|
|
41
|
+
return tensor.to(device=device, non_blocking=non_blocking)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["resolve_device", "move_to_device"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from typing import Optional, Tuple, List
|
|
5
|
+
|
|
6
|
+
from .._torch_utils import move_to_device, resolve_device
|
|
7
|
+
from ..regression_kernels import Residualizer
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"dosage_vector_for_covariate",
|
|
11
|
+
"residualize_matrix_with_covariates",
|
|
12
|
+
"residualize_batch",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _coerce_tensor(value, device: torch.device) -> torch.Tensor:
|
|
17
|
+
return move_to_device(value, device) if torch.is_tensor(value) else torch.as_tensor(
|
|
18
|
+
value, dtype=torch.float32, device=device
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_gen(seed: int | None, device: str) -> torch.Generator | None:
|
|
23
|
+
if seed is None:
|
|
24
|
+
return None
|
|
25
|
+
g = torch.Generator(device=device)
|
|
26
|
+
g.manual_seed(int(seed) & ((1 << 63) - 1))
|
|
27
|
+
return g
|
|
28
|
+
|
|
29
|
+
def dosage_vector_for_covariate(genotype_df: pd.DataFrame, variant_id: str,
|
|
30
|
+
sample_order: pd.Index, missing: float | int | None) -> np.ndarray:
|
|
31
|
+
"""Fetch a dosage row aligned to samples; impute 'missing' to mean of observed."""
|
|
32
|
+
if not genotype_df.columns.equals(sample_order):
|
|
33
|
+
row = genotype_df.loc[variant_id, sample_order].to_numpy(dtype=np.float32, copy=True)
|
|
34
|
+
else:
|
|
35
|
+
row = genotype_df.loc[variant_id].to_numpy(dtype=np.float32, copy=True)
|
|
36
|
+
if missing is not None:
|
|
37
|
+
mm = (row == missing)
|
|
38
|
+
if mm.any():
|
|
39
|
+
if (~mm).any():
|
|
40
|
+
row[mm] = row[~mm].mean(dtype=np.float32)
|
|
41
|
+
else:
|
|
42
|
+
row[mm] = 0.0
|
|
43
|
+
return row
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def residualize_matrix_with_covariates(
|
|
47
|
+
Y: torch.Tensor, C: Optional[pd.DataFrame], device: str
|
|
48
|
+
) -> Tuple[torch.Tensor, Optional[Residualizer]]:
|
|
49
|
+
"""
|
|
50
|
+
Residualize (features x samples) matrix Y against covariates C across samples.
|
|
51
|
+
Returns residualized Y and a Residualizer (or None if no covariates).
|
|
52
|
+
"""
|
|
53
|
+
if C is None:
|
|
54
|
+
return Y, None
|
|
55
|
+
dev = resolve_device(device)
|
|
56
|
+
C_t = torch.tensor(C.values, dtype=torch.float32, device=dev)
|
|
57
|
+
rez = Residualizer(C_t)
|
|
58
|
+
(Y_resid,) = rez.transform(move_to_device(Y, dev), center=True)
|
|
59
|
+
return Y_resid, rez
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def residualize_batch(
|
|
63
|
+
y, G: torch.Tensor, H: Optional[torch.Tensor],
|
|
64
|
+
rez: Optional[Residualizer], center: bool = True, group: bool = False,
|
|
65
|
+
) -> Tuple[object, torch.Tensor, Optional[torch.Tensor]]:
|
|
66
|
+
"""
|
|
67
|
+
Residualize y, G, and optional H with the same Residualizer.
|
|
68
|
+
- If group=False: y is a single vector (n,), returns (y_resid 1D, G_resid, H_resid).
|
|
69
|
+
- If group=True: y is a stack (k x n) or list of length k, returns (list_of_k 1D tensors, G_resid, H_resid).
|
|
70
|
+
"""
|
|
71
|
+
if rez is not None and hasattr(rez, "Q_t") and getattr(rez.Q_t, "numel", lambda: 0)() > 0:
|
|
72
|
+
dev = resolve_device(rez.Q_t.device)
|
|
73
|
+
else:
|
|
74
|
+
dev = resolve_device(G.device)
|
|
75
|
+
G = move_to_device(G, dev)
|
|
76
|
+
if H is not None:
|
|
77
|
+
H = move_to_device(H, dev)
|
|
78
|
+
|
|
79
|
+
if rez is None:
|
|
80
|
+
# Pass-through; normalize return type for group mode
|
|
81
|
+
if group:
|
|
82
|
+
if isinstance(y, (list, tuple)):
|
|
83
|
+
y_list = [torch.as_tensor(yi) if not torch.is_tensor(yi) else yi
|
|
84
|
+
for yi in y]
|
|
85
|
+
else:
|
|
86
|
+
Y = y if torch.is_tensor(y) else torch.as_tensor(y)
|
|
87
|
+
y_list = [Y] if Y.ndim == 1 else [Y[i, :] for i in range(Y.shape[0])]
|
|
88
|
+
return y_list, G, H
|
|
89
|
+
return (y if torch.is_tensor(y) else torch.as_tensor(y, dtype=torch.float32)), G, H
|
|
90
|
+
|
|
91
|
+
# Build Y on the same device as G/rez
|
|
92
|
+
if group:
|
|
93
|
+
if isinstance(y, (list, tuple)):
|
|
94
|
+
Y = torch.stack([_coerce_tensor(yi, dev) for yi in y], dim=0)
|
|
95
|
+
else:
|
|
96
|
+
Y = _coerce_tensor(y, dev)
|
|
97
|
+
if Y.ndim == 1:
|
|
98
|
+
Y = Y.unsqueeze(0)
|
|
99
|
+
else:
|
|
100
|
+
Y = _coerce_tensor(y, dev)
|
|
101
|
+
if Y.ndim == 1:
|
|
102
|
+
Y = Y.unsqueeze(0)
|
|
103
|
+
|
|
104
|
+
# Prepare matrices for one-pass residualization
|
|
105
|
+
mats: List[torch.Tensor] = [G]
|
|
106
|
+
H_shape = None
|
|
107
|
+
if H is not None:
|
|
108
|
+
m, n, pH = H.shape
|
|
109
|
+
H_shape = (m, n, pH)
|
|
110
|
+
mats.append(H.reshape(m * pH, n))
|
|
111
|
+
|
|
112
|
+
mats_resid = rez.transform(*mats, Y, center=center)
|
|
113
|
+
|
|
114
|
+
G_resid = mats_resid[0]
|
|
115
|
+
idx, H_resid = 1, None
|
|
116
|
+
if H is not None:
|
|
117
|
+
H_resid = mats_resid[idx].reshape(H_shape)
|
|
118
|
+
idx += 1
|
|
119
|
+
Y_resid = mats_resid[idx]
|
|
120
|
+
|
|
121
|
+
if group:
|
|
122
|
+
return [Y_resid[i, :] for i in range(Y_resid.shape[0])], G_resid, H_resid
|
|
123
|
+
else:
|
|
124
|
+
return Y_resid.squeeze(0), G_resid, H_resid
|