Principal Component Analysis#
The intention of this notebook is to perform the PCA analysis on genotype data and generate plots.
Overview#
Population structure is the classic confounder in genetic association: if ancestry correlates with both genotype and phenotype, unadjusted tests return associations that are real but not causal. The remedy is to compute principal components of the genotype matrix and carry the leading ones as covariates.
Components are computed on unrelated individuals and the remaining related samples are
projected back into that space, so relatives cannot distort the axes but every sample still
gets coordinates. The sequence is: remove related individuals, LD-prune the variants, run
PCA on the unrelated set, then exclude PCA-space outliers. Relatedness estimation and
sample QC happen upstream in GWAS_QC.ipynb.
The workflows cover the whole job rather than just the decomposition: pca_plink and
flashpca compute components, project_samples places new samples onto axes computed
elsewhere, plot_pca produces the scree and scatter plots you inspect, and
detect_outliers flags samples that sit far from any cluster.
Steps to generate a PCA include:
removing related individuals
pruning variants in linkage disequilibrium (LD)
performing PCA analysis on the genotypes of the unrelated individuals
excluding outlier samples in the PCA space for individuals of homogeneous self-reported ancestry. These outliers may suggest poor genotyping quality or distant relatedness.
Limitations:
Some of the PCs may capture LD structure rather than population structure, which decreases power to detect associations in these regions of high LD.
When projecting a new study dataset onto the PCA space computed from a reference dataset, the projected PCs are shrunk toward 0 in the new dataset.
PC scores may capture outliers arising from family structure, population structure or other reasons. Detecting and removing these individuals can be worthwhile, either to maximise the population structure captured by the PCA when only a few outliers are involved, or to restrict analyses to genetically homogeneous samples.
When to run it. After genotype QC and LD pruning, before covariate preprocessing; the components become part of the covariate file used by the association scan.
Input#
--genoFileoutput/gwas_qc/genotype/protocol_example.genotype.merged.plink_qc.protocol_example.king.unrelated.plink_qc.prune.bed(the LD-pruned PLINK bundle for the unrelated individuals, as produced by GWAS QC. PCA is computed on these samples)--phenoFile: a phenotype or label table carrying anIIDcolumn, optionallyFID, used to label and colour the projected samples. A PLINK.fammay be used instead. Its extra columns are what--pop-coland--label-colselect from. Exampleinput/covariate/protocol_example.pca_pheno.txt, 60 samples and 7 columns:FID IID msex age_death pmi study race SAMPLE_001 SAMPLE_001 1 90.97 10.57 1 1 SAMPLE_002 SAMPLE_002 1 80.24 7.87 0 1 SAMPLE_003 SAMPLE_003 1 83.9 9.93 0 1
--pca-model: the fitted PCA model to project onto, used by the projection step rather than the fitting step.--name: the stem of the output files. Required.--cwd: the directory outputs are written to.--k: how many principal components to compute,20by default.--maha-k: how many components the Mahalanobis outlier distance is computed over,5by default.--pop-coland--pops: the phenotype column naming each sample’s population and the subset of populations to analyse, for admixed or multi-ancestry data.--label-col: the phenotype column used to colour points in the PCA plots.--keep-samplesand--remove-samples: sample lists to restrict or exclude before fitting.--keep-variants: a variant list to restrict to.--maf-filter,--mac-filter,--geno-filterand--mind-filter: the PLINK frequency, count, variant-missingness and sample-missingness thresholds applied before PCA.
Output#
{cwd}/{name}.pca.rdsand{cwd}/{name}.pca.txt- the fitted PCA model and the PC scores for the unrelated samples. Every path below sits under--cwdand shares the stem set by--name.{name}.pca.scree.txtand{name}.pca.scree.png- variance explained per component, as a table and a plot. The table is whatcovariate_formattingreads to choose how many PCs to keep:PCs PVE PVE_cum 1 0.05 0.05 2 0.05 0.1
{name}.pca.mahalanobis.rds,.mahalanobis_hist.pngand.mahalanobis_qq.png- the per-population Mahalanobis distances and their diagnostic plots.{name}.pca.outliers- the samples flagged as PC outliers, an index and a sample ID per line:0 SAMPLE_001 0 SAMPLE_026 0 SAMPLE_050
{name}.pca.pc.png- the PCA plot.{name}.projected.rds,.projected.txtand.projected.pc.png- the related individuals projected back onto the model, and the plot including them.{name}.pca.analysis_summary.md- a written summary of the run.
The pca_plink sanity-check workflow declares {genoFile}.pca.eigenvec, but no .eigenvec file ships in output/pca_uf; only the flashpca products are present.
Minimal Working Example#
Note: parameters set for the MWE are meant to let the MWE work to show the workflow procedures. They may be unrealistic and should not be used in practice. The pipeline has reasonable default values for what is suggested in practice for most of the parameters.
Step 1. Estimate kinship in the sample (prerequisite)#
Before PCA it is necessary to know which individuals are related, because PCA must be computed on an unrelated subset and the related individuals are projected back afterwards. This step runs the king workflow from GWAS_QC to estimate pairwise kinship and split the samples into unrelated and related sets. It is an upstream prerequisite — its outputs (the king.unrelated and king.related PLINK bundles) feed the PCA steps below.
Timing: TBD (on toy dataset)
sos run pipeline/GWAS_QC.ipynb king \
--cwd output/gwas_qc/kinship \
--genoFile output/gwas_qc/plink/protocol_example.genotype.merged.plink_qc.bed \
--name protocol_example.king \
--keep-samples output/gwas_qc/genotype/protocol_example.rnaseq.bed.sample_genotypes.txt
Step 2. Sample selection and QC of the genotype data (prerequisite)#
QC the genotypes that will go into PCA: filter on MAF, sample/variant missingness and Hardy-Weinberg equilibrium, and LD-prune the variants. You can restrict to one population or to the related / unrelated split. Here the QC and LD-pruning are applied to the unrelated individuals to build the PCA reference, and separately extract the same variants for the related individuals so they can be projected later. These qc / qc_no_prune calls come from GWAS_QC and are upstream prerequisites for the PCA steps.
Analysis by population (admixed / multi-ancestry data)#
The Steps 1–4 above produce a single PCA for the whole cohort, which is appropriate for a homogeneous population. If your cohort contains multiple ancestry groups, run the per-population workflow below instead: split the samples by population, then repeat the same QC → PCA → projection steps separately within each population.
pheno <- read.table("input/covariate/protocol_example.pca_pheno.txt", header = TRUE, stringsAsFactors = FALSE)
if (all(pheno$FID == pheno$IID)) pheno$FID <- "0"
for (i in 1:3) {
race <- subset(pheno, race == i)
race_id <- cbind(race$FID, race$IID)
write.table(race_id,
paste0("output/pca_uf/protocol_example.ID.", i, ".txt"),
quote = FALSE, sep = "\t", col.names = FALSE, row.names = FALSE)
}
Command Interface#
sos run pipeline/PCA.ipynb -h
usage: sos run pipeline/PCA.ipynb [workflow_name | -t targets] [options] [workflow_options]
workflow_name: Single or combined workflows defined in this script
targets: One or more targets to generate
options: Single-hyphen sos parameters (see "sos run -h" for details)
workflow_options: Double-hyphen workflow-specific parameters
Workflows:
pca_plink
flashpca
project_samples
plot_pca
detect_outliers
Global Workflow Options:
--modular-script-dir code/script (as path)
--cwd output (as path)
the output directory for generated files
--name VAL (as str, required)
A string to identify your analysis run
--pop-col ''
Name of the population column in the phenoFile
--pops (as list)
Name of the populations (from the population column) you
would like to plot and show on the PCA plot
--label-col ''
Name of the color label column in the phenoFile; can be
the same as population column. Can also be a separate
column eg a "super population" column as a way to enable
you to combine selected populations based on another
column.
--k 20 (as int)
Number of Principal Components to output,must be
consistant between flashpca run and project samples run
(flashpca partial PCA method).
--maha-k 5 (as int)
Number of Principal Components based on which outliers
should be evaluated. Default is 5 but this should be
based on examine the scree plot
--[no-]homogeneous (default to False)
Homogeneity of populations. Set to --homogeneous when
true and --no-homogeneous when false
--job-size 1 (as int)
For cluster jobs, number commands to run per job
--walltime 5h
Wall clock time expected
--mem 16G
Memory expected
--numThreads 10 (as int)
Number of threads
Sections
pca_plink: PCA command with PLINK, as a sanity check
Workflow Options:
--genoFile VAL (as path, required)
PLINK binary file
flashpca_1: Run PCA analysis using flashpca
Workflow Options:
--genoFile VAL (as path, required)
Plink binary file
--phenoFile path(f'{genoFile}'.replace(".bed", ".fam").replace(".pgen", ".psam"))
The phenotypic file
--min-pop-size 2 (as int)
minimum population size to consider in the analysis
--stand binom2
How to standardize X before PCA
project_samples_1: Project back to PCA model additional samples
Workflow Options:
--genoFile VAL (as path, required)
Plink binary file
--phenoFile path(f'{genoFile}'.replace(".bed", ".fam").replace(".pgen", ".psam"))
The phenotypic file
--stand binom2
How to standardize X before PCA
--pca-model f'{cwd}/{phenoFile:bn}{("."+name) if name else ""}.{(suffix+".") if suffix != "" else ""}pca.rds'
plot_pca: Plot PCA results. Can be used independently as
"plot_pca" or combined with other workflow as eg
"flashpca+plot_pca"
Workflow Options:
--outlier-file . (as path)
--plot-data VAL (as path, required)
--min-axis ''
--max-axis ''
detect_outliers: Calculate Mahalanobis distance per population and report
outliers
Workflow Options:
--prob 0.997 (as float)
Set the probability to remove outliers eg 0.95 or 0.997
--pval 0.05 (as float)
Mahalanobis distance p-value cutoff
--[no-]robust (default to True)
Robust Mahalanobis to outliers
--pca-result VAL (as path, required)
flashpca_2, project_samples_2:
Workflow Options:
--prob 0.997 (as float)
Set the probability to remove outliers eg 0.95 or 0.997
--[no-]robust (default to True)
Robust Mahalanobis to outliers
flashpca_3, project_samples_3:
Workflow implementation#
[global]
parameter: modular_script_dir = path('code/script') # override with --modular-script-dir
# the output directory for generated files
parameter: cwd = path("output")
# A string to identify your analysis run
parameter: name = str
# Name of the population column in the phenoFile
parameter: pop_col = ""
# Name of the populations (from the population column) you would like to plot and show on the PCA plot
parameter: pops = []
# Name of the color label column in the phenoFile; can be the same as population column. Can also be a separate column eg a "super population" column as a way to enable you to combine selected populations based on another column.
parameter: label_col = ""
# Number of Principal Components to output,must be consistant between flashpca run and project samples run (flashpca partial PCA method).
parameter: k = 20
# Number of Principal Components based on which outliers should be evaluated. Default is 5 but this should be based on examine the scree plot
parameter: maha_k = 5
# Homogeneity of populations. Set to --homogeneous when true and --no-homogeneous when false
parameter: homogeneous = False
import re
# For cluster jobs, number commands to run per job
parameter: job_size = 1
# Wall clock time expected
parameter: walltime = "5h"
# Memory expected
parameter: mem = "16G"
# Number of threads
parameter: numThreads = 10
suffix = '_'.join(pops)
cwd = path(f"{cwd:a}")
if not pop_col:
homogeneous = True
# Determine if the file is in PLINK1 (BED/BIM/FAM) or PLINK2 (PGEN/PVAR/PSAM) format
def determine_plink_format(file_path):
"""
Determine the PLINK file format based on file extensions and companion files.
Args:
file_path (str or Path): Path to the input file
Returns:
str: 'plink1' or 'plink2'
"""
# Convert to string if it's a Path object
file_path = str(file_path)
# Check direct file extensions
if file_path.endswith('.bed'):
return 'plink1'
elif file_path.endswith('.pgen'):
return 'plink2'
# If the file doesn't have a standard extension, try to infer format
try:
# Remove the file extension if present
base_path = file_path.rsplit('.', 1)[0] if '.' in file_path else file_path
# Check for PLINK1 companion files
plink1_companion_files = [
f"{base_path}.bim",
f"{base_path}.fam"
]
# Check for PLINK2 companion files
plink2_companion_files = [
f"{base_path}.pvar",
f"{base_path}.psam"
]
# Check PLINK1 format
if all(os.path.exists(f) for f in plink1_companion_files):
return 'plink1'
# Check PLINK2 format
if all(os.path.exists(f) for f in plink2_companion_files):
return 'plink2'
except Exception as e:
print(f"Error determining PLINK format: {e}")
# Default to PLINK1 if can't determine
return 'plink1'
# Get the appropriate PLINK command prefix
def get_plink_command_prefix(file_path):
format_type = determine_plink_format(file_path)
if format_type == 'plink1':
return "--bfile"
else: # plink2
return "--pfile"
def normalize_phenoFile(pheno_path, out_dir):
"""Return a phenoFile path where FID=0 if the file has FID==IID in every data row.
Writes a normalized copy to out_dir when needed; otherwise returns the original path."""
import os, csv
pheno_path = str(pheno_path)
with open(pheno_path) as fh:
rows = [r for r in csv.reader(fh, delimiter='\t')]
if not rows:
return path(pheno_path)
# Detect header: first row is a header when its first cell is not a sample value
has_header = rows[0][0].upper() in ('FID', '#FID', 'IID', 'SAMPLE', 'SAMPLE_ID')
data_rows = rows[1:] if has_header else rows
if not data_rows or not all(len(r) >= 2 and r[0] == r[1] for r in data_rows):
return path(pheno_path)
# FID==IID everywhere — write a normalized copy to out_dir with FID=0
out_path = os.path.join(str(out_dir), os.path.basename(pheno_path))
with open(out_path, 'w', newline='') as fh:
w = csv.writer(fh, delimiter='\t')
if has_header:
w.writerow(rows[0])
for r in data_rows:
r[0] = '0'
w.writerow(r)
return path(out_path)
# PCA command with PLINK, as a sanity check
[pca_plink]
# PLINK binary file
parameter: genoFile = path
input: genoFile
plink_command = get_plink_command_prefix(_input)
output: f'{cwd}/{genoFile:bn}.pca.eigenvec'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads, tags = f'{step_name}_{_output[0]:bn}'
bash: expand = "${ }", stderr = f'{_output[0]:n}.stderr', stdout = f'{_output[0]:n}.stdout'
plink2 ${plink_command} ${_input:n} --out ${_output:n} --pca ${k}
PCA analysis#
# Run PCA analysis using flashpca
[flashpca_1]
# Plink binary file
parameter: genoFile = path
# The phenotypic file
parameter: phenoFile = path(f'{genoFile}'.replace(".bed", ".fam").replace(".pgen", ".psam"))
# minimum population size to consider in the analysis
parameter: min_pop_size = 2
# How to standardize X before PCA
parameter: stand = "binom2"
## Input genoFile here is for unrelated samples
phenoFile = normalize_phenoFile(phenoFile, cwd)
input: genoFile, phenoFile
output: f'{cwd}/{phenoFile:bn}{("."+name) if name else ""}.{(suffix+".") if suffix != "" else ""}pca.rds'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads, tags = f'{step_name}_{_output[0]:bn}'
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout'
Rscript ${modular_script_dir}/data_preprocessing/genotype/PCA.R \
--step flashpca \
--cwd "${cwd}" \
--genoFile "${_input[0]}" \
--phenoFile "${_input[1]}" \
--output "${_output}" \
--stand "${stand}" \
--min-pop-size ${min_pop_size} \
--homogeneous "${homogeneous}" \
--pop-col "${pop_col}" \
--label-col "${label_col}" \
--pops "${",".join([str(x) for x in pops])}" \
--k ${k} \
--maha-k ${maha_k} \
--numThreads ${numThreads}
Plot PCA results#
# Plot PCA results.
# Can be used independently as "plot_pca" or combined with other workflow as eg "flashpca+plot_pca"
[plot_pca]
parameter: outlier_file = path()
parameter: plot_data = path
parameter: min_axis = ""
parameter: max_axis = ""
input: plot_data
output: f'{cwd}/{_input:bn}.pc.png',
f'{cwd}/{_input:bn}.scree.png',
f'{cwd}/{_input:bn}.scree.txt'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = 1, tags = f'{step_name}_{_output[0]:bn}'
bash: expand= "${ }", stderr = f'{_output[0]:n}.stderr', stdout = f'{_output[0]:n}.stdout'
Rscript ${modular_script_dir}/data_preprocessing/genotype/PCA.R \
--step plot_pca \
--cwd "${cwd}" \
--plot-data "${_input}" \
--outlier-file "${outlier_file}" \
--min-axis "${min_axis}" \
--max-axis "${max_axis}" \
--pop-col "${pop_col}" \
--label-col "${label_col}" \
--pops "${",".join([str(x) for x in pops])}" \
--k ${k} \
--output-pc-plot "${_output[0]}" \
--output-scree-plot "${_output[1]}" \
--output-scree-text "${_output[2]}" \
--numThreads ${numThreads}
Detect outliers#
# Calculate Mahalanobis distance per population and report outliers
[detect_outliers]
# Set the probability to remove outliers eg 0.95 or 0.997
parameter: prob = 0.997
# Mahalanobis distance p-value cutoff
parameter: pval = 0.05
# Robust Mahalanobis to outliers
parameter: robust = True
parameter: pca_result = path
input: pca_result
output: distance=f'{_input:n}.mahalanobis.rds',
identified_outliers=f'{_input:n}.outliers',
analysis_summary=f'{_input:n}.analysis_summary.md',
qqplot_mahalanobis=f'{_input:n}.mahalanobis_qq.png',
hist_mahalanobis=f'{_input:n}.mahalanobis_hist.png'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = 1, tags = f'{step_name}_{_output[0]:bn}'
bash: expand= "${ }", stderr = f'{_output[0]:n}.stderr', stdout = f'{_output[0]:n}.stdout'
Rscript ${modular_script_dir}/data_preprocessing/genotype/PCA.R \
--step detect_outliers \
--cwd "${cwd}" \
--pca-result "${_input}" \
--prob ${prob} \
--pval ${pval} \
--robust ${"TRUE" if robust else "FALSE"} \
--pop-col "${pop_col}" \
--k ${k} \
--distance-output "${_output['distance']}" \
--identified-outliers-output "${_output['identified_outliers']}" \
--analysis-summary-output "${_output['analysis_summary']}" \
--qqplot-output "${_output['qqplot_mahalanobis']}" \
--hist-output "${_output['hist_mahalanobis']}" \
--numThreads ${numThreads}
Add plot and outlier detection to PCA steps#
[flashpca_2, project_samples_2]
# Set the probability to remove outliers eg 0.95 or 0.997
parameter: prob = 0.997
# Robust Mahalanobis to outliers
parameter: robust = True
output: distance=f'{_input:n}.mahalanobis.rds',
identified_outliers=f'{_input:n}.outliers',
analysis_summary=f'{_input:n}.analysis_summary.md',
qqplot_mahalanobis=f'{_input:n}.mahalanobis_qq.png',
hist_mahalanobis=f'{_input:n}.mahalanobis_hist.png'
sos_run("detect_outliers", pca_result=_input, prob=prob, robust=robust)
[flashpca_3, project_samples_3]
input: output_from(1), output_from(2)['identified_outliers']
outliers = [x.strip() for x in open(_input[1]).readlines() if x.strip()]
output: f"{cwd}/{_input[0]:bn}.pc.png",
f"{cwd}/{_input[0]:bn}.scree.png"
sos_run("plot_pca", plot_data = _input[0], outlier_file = _input[1] if len(outliers) else path())