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.
@@ -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
+
@@ -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,11 @@
1
+ from .nominal import map_nominal
2
+ from .permutations import map_permutations
3
+ from .independent import map_independent
4
+ from .mapper import CisMapper
5
+
6
+ __all__ = [
7
+ "map_nominal",
8
+ "map_permutations",
9
+ "map_independent",
10
+ "CisMapper"
11
+ ]
@@ -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