Phenotype data imputation#

Imputes missing values in a molecular phenotype matrix, so downstream models see a complete matrix.

Overview#

Molecular phenotype matrices are rarely complete: a feature can be unmeasured in a sample for technical reasons, and the models downstream generally require a full matrix. Dropping every feature with a missing entry throws away real signal, so this step imputes the gaps instead.

Empirical Bayes matrix factorisation is the primary method here, EBMF for a single matrix and gEBMF when the data are grouped, following Qi et al., medRxiv 2023. Seven alternatives are also provided - missForest, missXGBoost, k-nearest neighbours, SoftImpute, mean imputation and limit-of-detection imputation - so a method can be matched to the assay.

When to run it. After phenotype QC and normalisation, before covariate preprocessing and the association scan.

Input#

  • --phenoFile: the molecular phenotype matrix in BED layout, the first four columns being #chr, start, end and ID and the rest one column per sample, with missing entries written as NA. Example input/proteomics/protocol_example.protein.missing.bed.gz, a toy matrix with roughly 10% of values set to missing so the methods have gaps to fill:

    #chr   start   end     ID                            SAMPLE_001          SAMPLE_002
    chr12  752578  752579  chr12_ENSG00000060237_Q9H4A3  0.238966360190167   -0.611171227886468
    chr12  990508  990509  chr12_ENSG00000082805_Q8IUD2  NA                  -1.86313205860919
    
  • --num_factor: the number of factors for the EBMF and gEBMF models. It must be smaller than the number of samples; the toy set has 60, so the examples use 30.

  • --cwd: the directory outputs are written to.

  • --save_flash: off by default. When set, gEBMF also writes the fitted flashier factor object.

  • --no-qc-prior-to-impute: leaves quality control on by default. Passing it skips QC, which also means the QC matrix is not available to the output.

Output#

  • {cwd}/{phenoFile}.imputed.bed.gz - the imputed matrix in the same BED layout as the input, bgzipped and tabix-indexed, with a .tbi beside it. Example output/phenotype_imputation_uf/protocol_example.protein.missing.bed.imputed.bed.gz, where the gaps shown in the input preview above are filled:

    #chr	start	end	ID	SAMPLE_001	SAMPLE_002	SAMPLE_003	SAMPLE_004	SAMPLE_005	SAMPLE_006	SAMP
    chr12	752578	752579	chr12_ENSG00000060237_Q9H4A3	0.238966360190167	-0.611171227886468	1.
    chr12	990508	990509	chr12_ENSG00000082805_Q8IUD2	-0.762712503460405	-1.86313205860919	1.
    
  • {cwd}/{phenoFile}.gEBMF_factors.rds - the saved flashier factor object from a gEBMF fit, written only when --save_flash True is given. It is a binary R object, so it has no text preview; load it with readRDS. Example output/phenotype_imputation_uf/protocol_example.protein.missing.bed.gEBMF_factors.rds.

Minimal Working Example#

Eight imputation methods plus a filter that drops features too sparse to impute. Pick one method. Two practical notes for the toy data: --num_factor for the EBMF and gEBMF factor models must be smaller than the number of samples, which is 60 here, so the examples use --num_factor 30; and leave quality control enabled, that is do not pass --no-qc-prior-to-impute, so the QC matrix used for the output is available to every method.

Grouped Empirical Bayes Matrix Factorization (gEBMF)#

Grouped Empirical Bayes Matrix Factorization. Extends EBMF by fitting factors within row groups (here, by chromosome), so structure shared across molecular features in the same group is borrowed when filling in missing values. This is the recommended default for the protocol.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb gEBMF \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf \
    --num_factor 30

Empirical Bayes Matrix Factorization (EBMF)#

Empirical Bayes Matrix Factorization. Decomposes the phenotype matrix into a small number of latent factors with adaptive shrinkage priors, then reconstructs the matrix to impute missing entries. Captures global low-rank structure across all samples and features.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb EBMF \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf \
    --num_factor 30

missForest#

A non-parametric, iterative random-forest imputation. Each feature with missing values is predicted in turn from the other features using a random forest, repeating until the imputed values stabilize. Handles non-linear relationships but is computationally heavier.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb missforest \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf

missXGBoost#

Iterative imputation using gradient-boosted trees (XGBoost) as the per-feature predictor instead of random forests. Often faster and more accurate than missForest on large matrices while still modeling non-linear dependencies between features.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb missxgboost \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf

KNN (k-nearest-neighbours)#

k-Nearest-Neighbours imputation. A missing entry is filled with a (distance-weighted) average of the corresponding values from the k most similar samples. Simple and fast, and works well when samples cluster into similar profiles.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb knn \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf

SoftImpute#

Matrix-completion via iterative soft-thresholded singular value decomposition. Assumes the phenotype matrix is approximately low-rank and recovers missing entries by repeatedly shrinking the singular values. A good linear baseline for structured data.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb soft \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf

Mean imputation#

Mean imputation. Each missing value is replaced by the mean of the observed values for that feature. The simplest baseline; fast but ignores correlations between samples and features.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb mean \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf

LOD (limit-of-detection) imputation#

Limit-of-detection imputation. Missing values are replaced with a low constant derived from the smallest observed values, appropriate when missingness is driven by measurements falling below an assay’s detection threshold (e.g., low-abundance proteins/metabolites).

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb lod \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz \
    --cwd output/phenotype_imputation_uf

Drop features that are too sparse to impute#

bed_filter_na removes rows whose missing fraction is above the threshold, so that the imputation methods are not asked to invent values for features with almost no data. Run it before a method rather than instead of one.

Timing: TBD (on toy dataset)

sos run pipeline/phenotype_imputation.ipynb bed_filter_na \
    --cwd output/phenotype_imputation_uf \
    --phenoFile input/proteomics/protocol_example.protein.missing.bed.gz

Command Interface#

sos run pipeline/phenotype_imputation.ipynb -h
usage: sos run pipeline/phenotype_imputation.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:
  EBMF
  gEBMF
  missforest
  missxgboost
  knn
  soft
  mean
  lod
  bed_filter_na

Global Workflow Options:
  --modular-script-dir code/script (as path)
  --cwd output (as path)
                        Work directory & output directory
  --phenoFile VAL (as path, required)
                        Molecular phenotype matrix
  --[no-]qc-prior-to-impute (default to True)
                        QC before imputation to remove unqualified features
  --job-size 1 (as int)
                        For cluster jobs, number commands to run per job
  --walltime 72h
                        Wall clock time expected
  --mem 16G
                        Memory expected
  --numThreads 20 (as int)
                        Number of threads
  --container ''
  --entrypoint ''

Sections
  EBMF:
    Workflow Options:
      --prior 'ebnm_point_laplace'
                        prior distribution of loadings and factors
      --varType 1
      --num-factor 60
  gEBMF:
    Workflow Options:
      --nCores 1
      --num-factor 60
      --backfit-iter 3
      --[no-]save-flash (default to True)
      --[no-]null-check (default to False)
  missforest:
  missxgboost:
  knn:
  soft:
  mean:
  lod:
  bed_filter_na:
    Workflow Options:
      --rank-max 50 (as int)
      --lambda-hyp 30 (as int)
      --impute-method soft
      --tol-missing 0.05 (as float)
                        Tolerance of missingness rows with missing rate larger
                        than tol_missing will be removed, with missing rate
                        smaller than tol_missing will be mean_imputed. Say if we
                        want to keep rows with less than 5% missing, then we use
                        0.05 as tol_missing.

Workflow implementation#

[global]
parameter: modular_script_dir = path('code/script')  # override with --modular-script-dir
# Work directory & output directory
parameter: cwd = path("output")
# Molecular phenotype matrix
parameter: phenoFile = path
# QC before imputation to remove unqualified features 
parameter: qc_prior_to_impute = True
# For cluster jobs, number commands to run per job
parameter: job_size = 1
# Wall clock time expected
parameter: walltime = "72h"
# Memory expected
parameter: mem = "16G"
# Number of threads
parameter: numThreads = 20
parameter: container = ""
import re
parameter: entrypoint= ""
[EBMF]
# prior distribution of loadings and factors
parameter: prior = "ebnm_point_laplace"
parameter: varType = '1'
parameter: num_factor = '60'
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}', cores = numThreads  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step EBMF \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --prior "${prior}" \
        --varType "${varType}" \
        --num-factor ${num_factor} \
        --numThreads ${numThreads}
[gEBMF]
parameter: nCores = '1'
parameter: num_factor = '60'
parameter: backfit_iter = '3'
parameter: save_flash = True
parameter: null_check = False
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}', cores = numThreads  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step gEBMF \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --num-factor ${num_factor} \
        --nCores ${nCores} \
        --backfit-iter ${backfit_iter} \
        --save-flash ${"TRUE" if save_flash else "FALSE"} \
        --null-check ${"TRUE" if null_check else "FALSE"} \
        --numThreads ${numThreads}
[missforest]
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}'  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step missforest \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --numThreads ${numThreads}
[missxgboost]
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}'  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    bash ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.sh missxgboost \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --numThreads ${numThreads}
[knn]
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}'  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step knn \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --numThreads ${numThreads}
[soft]
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}'  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step soft \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --numThreads ${numThreads}
[mean]
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}'  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step mean \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --numThreads ${numThreads}
[lod]
input: phenoFile
output: f'{cwd:a}/{_input:bn}.imputed.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime,  mem = mem, tags = f'{step_name}_{_output:bn}'  
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step lod \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --qc-prior-to-impute ${"TRUE" if qc_prior_to_impute else "FALSE"} \
        --numThreads ${numThreads}
[bed_filter_na]
parameter: rank_max = 50 # max rank estimated in the per-chr methyl matrix
parameter: lambda_hyp = 30 # hyper par, indicating the importance of the nuclear norm
parameter: impute_method = "soft"
# Tolerance of missingness rows with missing rate larger than tol_missing will be removed,
# with missing rate smaller than tol_missing will be mean_imputed. Say if we want to keep rows with less than 5% missing, then we use 0.05 as tol_missing.
parameter: tol_missing = 0.05
input: phenoFile
output: f'{cwd:a}/{_input:nn}.filter_na.{impute_method}.bed.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads
bash: expand= "${ }", stderr = f'{_output:n}.stderr', stdout = f'{_output:n}.stdout', container = container, entrypoint = entrypoint
    Rscript ${modular_script_dir}/data_preprocessing/phenotype/phenotype_imputation.R \
        --step bed_filter_na \
        --cwd "${cwd}" \
        --phenoFile "${_input}" \
        --output "${_output}" \
        --rank-max ${rank_max} \
        --lambda-hyp ${lambda_hyp} \
        --impute-method "${impute_method}" \
        --tol-missing ${tol_missing} \
        --numThreads ${numThreads}