Python 3 linear mixed-model solver for genome-wide association studies
Project description
pylmm3
A fast, lightweight linear mixed-model solver for genome-wide association studies.
pylmm3 is a Python 3 implementation of the EMMA/fastLMM linear mixed model (LMM) framework. It corrects for population stratification and cryptic relatedness in GWAS by modeling pairwise genetic similarity (kinship) as a random effect, then tests each SNP for an additional fixed effect on top of that background.
Two command-line tools and a clean Python API:
pylmmKinship— compute the realized relationship matrix (GRM) from PLINK genotype filespylmmGWAS— run the genome-wide association scan given a kinship matrix and phenotype file
Contents
- Quick Start
- Installation
- Running the Tools
- Two-Stage Workflow
- Python API
- CLI Reference
- Logging
- Output Format
- Testing
- How It Works
- Known Limitations
- Authors
- License
Quick Start
git clone https://github.com/TheJacksonLaboratory/pylmm3.git
cd pylmm3
uv sync
# Stage 1 — build the kinship matrix
uv run pylmmKinship --bfile /path/to/study study.kin
# Stage 2 — run the GWAS
uv run pylmmGWAS --bfile /path/to/study --kfile study.kin --phenofile study.phenos results.tsv
Installation
Choose the path that fits your situation. All three install numpy and scipy automatically.
Install uv first (if you don't have it):
curl -LsSf https://astral.sh/uv/install.sh | sh
Tier 1 — Just run the CLI tools (no clone, no project setup)
uv tool install puts pylmmGWAS and pylmmKinship on your PATH in an isolated
environment. This is the right path for anyone who just wants to run GWAS jobs.
uv tool install git+https://github.com/TheJacksonLaboratory/pylmm3.git
# Commands are now available globally:
pylmmKinship --bfile /data/study study.kin
pylmmGWAS --bfile /data/study --kfile study.kin --phenofile study.phenos out.tsv
Tier 2 — Use pylmm3 as a library inside your own project
# uv project
uv add git+https://github.com/TheJacksonLaboratory/pylmm3.git
# pip / any other tool
pip install git+https://github.com/TheJacksonLaboratory/pylmm3.git
Then import:
from pylmm3 import LMM, calculateKinship, runGWAS
Tier 3 — Local development (clone and edit source)
git clone https://github.com/TheJacksonLaboratory/pylmm3.git
cd pylmm3
uv sync # creates .venv, installs all dependencies + pylmm3 in editable mode
uv sync reads pyproject.toml and uv.lock for a fully reproducible environment.
See Running the Tools below for how to invoke the CLIs after sync.
Running the Tools
After uv sync there are two equally valid ways to run pylmm3.
Option 1 — uv run (no activation)
uv run transparently invokes commands inside .venv without requiring you to
activate anything first. Works from any shell, any directory — no PATH changes needed.
# CLI entry points (registered in pyproject.toml)
uv run pylmmKinship --bfile /data/study study.kin
uv run pylmmGWAS --bfile /data/study --kfile study.kin --phenofile study.phenos out.tsv
# Module invocation (identical result)
uv run python -m pylmm3.scripts.pylmmKinship --bfile /data/study study.kin
uv run python -m pylmm3.scripts.pylmmGWAS --bfile /data/study --kfile study.kin out.tsv
# Interactive Python with pylmm3 available
uv run python
Option 2 — Activate the virtual environment
Activate once per shell session; then call the tools directly.
source .venv/bin/activate
pylmmKinship --bfile /data/study study.kin
pylmmGWAS --bfile /data/study --kfile study.kin --phenofile study.phenos out.tsv
python -c "from pylmm3 import LMM, calculateKinship, runGWAS; print('ready')"
deactivate # when done
Two-Stage Workflow
The kinship matrix is the bridge between the two tools. Once computed it can be reused across multiple GWAS runs on the same cohort — testing different phenotypes or covariate configurations without re-building kinship.
Genotype files (.bed / .bim / .fam)
Phenotype file (.phenos)
│
▼
┌─────────────────┐
│ pylmmKinship │ ← reads genotypes, normalizes SNPs, builds K = W·Wᵀ / m
└─────────────────┘
│
study.kin (reusable across phenotypes)
study.kin.kva/.kve (optional; skip O(n³) eigen on next run)
│
▼
┌─────────────────┐
│ pylmmGWAS │ ← fits null LMM once, then tests each SNP
└─────────────────┘
│
▼
results.tsv
SNP_ID BETA BETA_SD F_STAT P_VALUE
Typical run — with logging and saved eigendecomposition
# Build kinship, save eigenvectors for reuse (INFO shows milestones + timing)
uv run pylmmKinship \
--bfile /data/study \
--efile study.eigen \
--log-level INFO \
study.kin
# Run GWAS — load saved eigens, skip the O(n³) decomposition at startup
uv run pylmmGWAS \
--bfile /data/study \
--kfile study.kin \
--eigen study.eigen \
--phenofile study.phenos \
--log-level INFO \
results.tsv
Python API
The public API exports four symbols:
from pylmm3 import LMM, calculateKinship, runGWAS, load_snp_matrix
Build a kinship matrix
import numpy as np
from pylmm3 import input as plink_input, calculateKinship
# Load genotypes from a PLINK binary fileset (.bed/.bim/.fam)
reader = plink_input.plink("study", type='b')
W = plink_input.load_snp_matrix(reader) # (n_individuals, n_snps), np.nan for missing
K = calculateKinship(W) # (n, n) realized relationship matrix
np.savetxt("study.kin", K) # plain-text format read by pylmmGWAS
Run a GWAS scan
from pylmm3 import runGWAS # vectorized fast path (gwas_fast)
from pylmm3 import input as plink_input
import numpy as np
# Load data
reader = plink_input.plink("study", type='b', phenoFile="study.phenos")
Y = reader.phenos[:, 0] # first phenotype column
K = np.loadtxt("study.kin")
# Fit null LMM once, scan all SNPs
results = runGWAS(Y, K, reader) # numpy structured array
# Access results
import pandas as pd
df = pd.DataFrame(results)
print(df.sort_values("P_VALUE").head(10))
Reference vs fast path:
from pylmm3 import runGWASis the vectorized implementation (gwas_fast.py), which is the same default used by the CLI. The reference per-SNP loop is available asfrom pylmm3.gwas import runGWASand produces numerically identical results (max relative error < 3×10⁻⁹).
Use the LMM class directly
from pylmm3 import LMM
from pylmm3 import input as plink_input
import numpy as np
# Load genotypes and phenotypes via the PLINK reader
reader = plink_input.plink("study", type='b', phenoFile="study.phenos")
Y = reader.phenos[:, 0] # first phenotype column
# Kinship is plain text written by pylmmKinship / np.savetxt
K = np.loadtxt("study.kin")
# Initialize — eigendecomposition computed here if Kva/Kve not provided
model = LMM(Y, K)
# Fit the null model
model.fit(REML=True)
print(f"Heritability: {model.optH:.3f} σ²: {model.optSigma:.4f}")
# Test a single SNP (genotype vector, length N, values in {0.0, 0.5, 1.0, nan})
# Apply model.nonmissing mask — LMM removes individuals with missing phenotype,
# so the SNP vector must be subset to the same individuals before calling association().
snp, snp_id = next(iter(reader))
ts, ps = model.association(snp[model.nonmissing].reshape(-1, 1))
print(f"{snp_id}: t = {ts:.4f} p = {ps:.2e}")
Note: The
verboseparameter onLMM()is accepted for backward compatibility but is ignored. UsePYLMM3_LOG_LEVEL=DEBUGor--log-level DEBUGto see internal detail. See Logging below.
CLI Reference
pylmmKinship
uv run pylmmKinship [options] --[bfile | tfile | emmaSNP] <base> <outfile>
| Flag | Required | Description |
|---|---|---|
--bfile <base> |
one of three | Base path for PLINK binary files (.bed / .bim / .fam) |
--tfile <base> |
one of three | Base path for PLINK text files (.tped / .tfam) |
--emmaSNP <file> |
one of three | EMMA-format genotype file |
--emmaNumSNPs <n> |
with --emmaSNP |
Number of SNPs in the EMMA file |
-e, --efile <base> |
no | Save eigendecomposition to <base>.kva and <base>.kve |
--log-level LEVEL |
no | Log verbosity: DEBUG, INFO, WARNING, ERROR (default: WARNING) |
-v, --verbose |
no | Shorthand for --log-level INFO |
<outfile> |
yes | Output path for the kinship matrix |
pylmmGWAS
uv run pylmmGWAS [options] --kfile <kin> --[bfile | tfile | emmaSNP] <base> <outfile>
Basic options
| Flag | Required | Default | Description |
|---|---|---|---|
--bfile <base> |
one of three | — | PLINK binary fileset base path |
--tfile <base> |
one of three | — | PLINK text fileset base path |
--emmaSNP <file> |
one of three | — | EMMA-format genotype file |
--emmaPHENO <file> |
no | — | EMMA-format phenotype file |
--emmaCOV <file> |
no | — | EMMA-format covariate file |
--kfile <file> |
yes | — | Pre-computed kinship matrix (.kin or .kin.gz) |
--phenofile <file> |
no | <bfile>.phenos |
PLINK phenotype file |
-p <int> |
no | 0 |
0-indexed phenotype column (counting from column 3) |
--covfile <file> |
no | — | PLINK covariate file |
<outfile> |
yes | — | Output path for GWAS results |
Advanced options
| Flag | Default | Description |
|---|---|---|
--eigen <base> |
— | Load pre-computed eigendecomposition (<base>.kva / <base>.kve); skips O(n³) decomp at startup |
--REML |
off | Use REML for per-SNP association tests (null model always uses REML regardless) |
--refit |
off | Re-estimate variance components at every SNP; more accurate, much slower |
--removeMissingGenotypes |
off | Drop individuals with missing genotypes per SNP instead of imputing with the mean |
--noMean |
off | Suppress automatic intercept when --covfile is provided |
--orig |
off | Use the reference per-SNP loop instead of the default vectorized scan |
--log-level LEVEL |
WARNING |
Log verbosity: DEBUG, INFO, WARNING, ERROR |
-v, --verbose |
off | Shorthand for --log-level INFO |
Logging
pylmm3 follows the standard Python library logging contract: it attaches a
NullHandler to the pylmm3 logger at import time and never calls
basicConfig() or installs a handler of its own. All loggers are named
pylmm3.<module> (e.g. pylmm3.gwas_fast, pylmm3.lmm) and propagate to
the root logger. The calling application installs the handler and decides how
loud each component should be.
Log levels
| Level | Default? | What you see |
|---|---|---|
ERROR |
always | Unrecoverable failures — bad BED magic, unknown file type |
WARNING |
always | Dropped individuals, missing kinship entries, multiple optima during heritability optimization |
INFO |
off | Per-step timings — SNP load, LMM construction, null fit (h + σ²), scan throughput, total elapsed |
DEBUG |
off | Internal detail — eigendecomposition breakdown, BED bytes per SNP, per-10k-SNP throughput ticks |
The default level is WARNING (nearly silent). A production run emits output only when something is wrong.
Running the pylmm3 CLIs directly
Via CLI flag:
uv run pylmmGWAS --log-level INFO ... results.tsv # milestones + timing
uv run pylmmGWAS --log-level DEBUG ... results.tsv # full internal trace
uv run pylmmGWAS --verbose ... results.tsv # shorthand for INFO
Via environment variable — useful for one-off debugging without changing the command line:
PYLMM3_LOG_LEVEL=DEBUG uv run pylmmGWAS --bfile study --kfile study.kin results.tsv
Priority when running the CLI: --log-level flag > PYLMM3_LOG_LEVEL env var > WARNING default.
Running pylmm3 through an orchestrator (e.g. plinkformatter)
When plinkformatter calls pylmm3 in-process, plinkformatter's configure()
owns the root handler and controls both packages. PYLMM3_LOG_LEVEL provides
independent per-package control:
| Command | plinkformatter | pylmm3 |
|---|---|---|
| default | WARNING (silent) | WARNING (silent) |
--verbose |
INFO | INFO — timings visible |
--log-level DEBUG |
DEBUG | DEBUG |
PYLMM3_LOG_LEVEL=DEBUG ./run.sh |
WARNING | DEBUG — pylmm3 only |
PYLMM3_LOG_LEVEL=WARNING --log-level DEBUG |
DEBUG | WARNING — plinkformatter only |
Using pylmm3 as a library in your own code
pylmm3 is silent by default (NullHandler). Configure your application's root
handler once at startup and pylmm3 loggers flow through it automatically.
Call pylmm3.log.configure() to set pylmm3's verbosity independently:
import logging
from pylmm3.log import configure as configure_pylmm3
# Your application installs the handler once
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
# Set pylmm3 to a different level if you want more or less detail
configure_pylmm3(logging.DEBUG) # or logging.WARNING to silence it
from pylmm3 import runGWAS # pylmm3 logs now flow through your handler
What INFO timing looks like
At --verbose (INFO) a typical GWAS run emits:
[INFO ] 2026-06-01 09:12:04.123 pylmm3.gwas_fast Read input for 1223 individuals in 0.041s
[INFO ] 2026-06-01 09:12:06.441 pylmm3.gwas_fast Read 231164x1223 kinship in 2.318s
[INFO ] 2026-06-01 09:12:06.892 pylmm3.gwas_fast LMM ready in 0.451s (eigendecomposition included unless precomputed)
[INFO ] 2026-06-01 09:12:07.204 pylmm3.gwas_fast Null fit: h=0.412 sigma=1.834 (0.312s)
[INFO ] 2026-06-01 09:12:14.817 pylmm3.gwas_fast Scanned 231164 SNPs in 7.613s — skipped 159 (0.1%) due to missing genotypes or low variance
[INFO ] 2026-06-01 09:12:14.819 pylmm3.gwas_fast Total: 9.003s
All output goes to stderr. The [LEVEL ] field is always 9 characters wide
so columns align across log lines.
Why %s-style formatting in logger calls
All logger calls in pylmm3 use %-style arguments, not f-strings:
# Correct — lazy: the string is never formatted if the level is disabled
logger.debug("BED bytes per SNP: %d", self.BytestoRead)
# Wrong — eager: the f-string is evaluated at the call site regardless of level
logger.debug(f"BED bytes per SNP: {self.BytestoRead}")
Python's logging module defers % substitution to Formatter.format(),
which is only called when a handler will actually emit the record. With
f-strings the interpolation happens before the level check — wasted work on
every suppressed call. In a tight loop over 250,000 SNPs this adds up.
Output Format
Kinship matrix (<outfile>)
Space-delimited n×n matrix written by numpy.savetxt. No header. Row and column
order matches the FAM/TFAM file exactly. Values near 0 indicate unrelated pairs;
values near 1 indicate identical individuals.
Eigendecomposition (<base>.kva, <base>.kve)
Written when -e <base> is passed to pylmmKinship. Plain-text files:
<base>.kva— eigenvalues of K, length n<base>.kve— eigenvectors of K, n × n
Pass to pylmmGWAS --eigen <base> to skip the decomposition on subsequent runs.
GWAS results (<outfile>)
Tab-separated. One header row, then one row per SNP. Monomorphic or all-missing
SNPs are written as nan across all value columns rather than being silently dropped.
| Column | Type | Description |
|---|---|---|
SNP_ID |
string | RS identifier from the .bim file |
BETA |
float | Effect size estimate — phenotype change per unit dosage |
BETA_SD |
float | Standard error of the effect estimate |
F_STAT |
float | t-statistic for the SNP association test (column name is a historical artifact — this is a t-statistic) |
P_VALUE |
float | Two-tailed p-value from the t-distribution with n − q degrees of freedom |
Testing
The test suite lives in tests/ and uses pytest.
It is fully self-contained — synthetic data is generated from a fixed random seed
and temporary PLINK filesets are written per test, so no external fixture files
are required.
uv run pytest -q # quiet — one line of summary
uv run pytest -v # verbose — one line per test
Run a subset while developing:
uv run pytest tests/test_gwas.py # one file
uv run pytest tests/test_lmm.py::test_tstat_matches_scipy # one test
uv run pytest -k gwas # by name pattern
uv run pytest -x # stop at first failure
What the suite covers
| File | Module under test | Focus |
|---|---|---|
tests/test_lmm.py |
pylmm3.lmm |
Missing-phenotype removal, eigendecomposition + clamping, fit(), single-SNP association(), tstat vs scipy.stats, and the REML log-likelihood staying finite when det(XᵀX) overflows (slogdet regression guard) |
tests/test_kinship.py |
pylmm3.kinship |
Shape/symmetry, center=True → trace(K) = n − 1, invariant-SNP dropping, NaN imputation |
tests/test_input.py |
pylmm3.input |
Genotype normalization, TPED/BED decoding, end-to-end EMMA/TPED/BED readers, phenotype NA/-9 → NaN |
tests/test_gwas.py |
pylmm3.gwas, pylmm3.gwas_fast |
Output structure, signal recovery, monomorphic/missing handling, and a cross-validation that the vectorized gwas_fast is numerically identical to the reference gwas |
Shared fixtures (seeded RNG, synthetic genotypes/kinship/phenotype, and temporary
PLINK filesets) live in tests/conftest.py.
How It Works
Kinship matrix
Each SNP is imputed (missing → column mean), standardized to zero mean and unit
variance, and invariant SNPs are dropped. If every SNP is invariant (a
genetically uniform cohort, e.g. a single strain), there is nothing to build K
from and calculateKinship raises NoVariantSNPsError (a ValueError
subclass) rather than returning a degenerate matrix. The realized relationship
matrix is:
K = W · Wᵀ / m
where W is the (n × m) matrix of standardized genotypes and m is the number of
valid SNPs retained. With center=True, K is further normalized so that
trace(K) = n − 1 (EMMA-style).
LMM and heritability estimation
The model is Y = X·β + u + ε where u ~ N(0, h·σ²·K) captures population
structure and ε ~ N(0, (1−h)·σ²·I) is the residual. Fitting requires optimizing
over the single free parameter h (heritability).
The key computational trick: K is decomposed once as K = V·Λ·Vᵀ via
scipy.linalg.eigh. Rotating Y and X into this eigenbasis diagonalizes the
covariance, reducing the O(n³) log-likelihood evaluation to O(n) per heritability
value. The profile likelihood is then maximized over a 100-point grid, with
Brent's method
applied to refine each local maximum.
Vectorized GWAS scan
The default scan (gwas_fast.py) batches fully-observed SNPs into blocks of 2000
and processes each block with a single BLAS dgemm call (Kve.T @ G), then
applies the Schur complement of the fixed covariate block — which is constant
across SNPs at the null-fit h — to compute per-SNP effect sizes and t-statistics
entirely in NumPy. This is algebraically identical to the reference per-SNP loop
in gwas.py (validated: max relative error < 3×10⁻⁹ across 231,164 SNPs) and
runs 50–200× faster in practice.
SNPs with missing genotypes or when --refit is active automatically fall back
to the per-SNP path.
Known Limitations
| Limitation | Detail |
|---|---|
| Memory | K is an n×n float64 matrix where n is the number of individuals in the cohort. At n = 10,000 that is ~800 MB; at n = 100,000 it is ~80 GB. |
| Single-threaded | No parallelism across SNPs. |
| REML with many covariates | --REML on the per-SNP path uses linalg.det(), which overflows when the covariate count q ≥ ~100. The null model uses slogdet and is safe. In practice GWAS runs use q = 2 (intercept + genotype) and are unaffected. |
--removeMissingGenotypes cost |
Dropping missing individuals triggers an O(n³) eigendecomposition recompute per affected SNP. Avoid this flag on cohorts with high missing-genotype rates. |
| Covariate missing values | The covariate file does not support missing values. Impute externally before passing to pylmmGWAS. |
--kfile2 |
Accepted by the parser but immediately exits with an error — the two-kinship confounding path is not implemented. |
Authors
pylmm3 is developed and maintained at The Jackson Laboratory:
| Name | |
|---|---|
| Matt Vincent | matt.vincent@jax.org |
| Nick Sebasco | nick.sebasco@jax.org |
pylmm3 is a Python 3 port of the original pylmm by Nicholas A. Furlotte.
License
Copyright © 2015 Nicholas A. Furlotte
Copyright © 2024–2026 The Jackson Laboratory
pylmm3 is free software licensed under the GNU Affero General Public License v3.0 or later.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pylmm3-0.2.3.tar.gz.
File metadata
- Download URL: pylmm3-0.2.3.tar.gz
- Upload date:
- Size: 62.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d39db2419e5c3f3aaefb045983a2e0008aabe42d18de50800bbb51799846bdbf
|
|
| MD5 |
e79d55fda6471c5404065d5e668e055c
|
|
| BLAKE2b-256 |
86814afa5f7701c6f00ce2b2e8529f2d0d696882fa6ad42a03991a68727a2f12
|
File details
Details for the file pylmm3-0.2.3-py3-none-any.whl.
File metadata
- Download URL: pylmm3-0.2.3-py3-none-any.whl
- Upload date:
- Size: 53.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2652b70eec229c5af5fa6f3bf99230ab8aea91d637dc79f422b4bddd74a61b40
|
|
| MD5 |
ea0ac7cba9c56fea79f4f603c80c5460
|
|
| BLAKE2b-256 |
eff3425537432818c0b6ce2b981c7b26758e36ba50330044343b3dede7caa846
|