Source code for acore.drift_correction.cpca_drift_correction
"""
Functions for metabolomics drift correction by
Common Principal Components Analysis (CPCA).
"""
import logging
import math
import numpy as np
import pandas as pd
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
logger = logging.getLogger(__name__)
[docs]
def check_missingness(df: pd.DataFrame, rows_to_check: list):
"""
This function checks for NAs in the data frame inside some user-provided rows.
Parameters
---------
df : samples as rows, features as columns
rows_to_check : list of rows that should be checked for missingness
Returns
--------
Boolean: True if there is missingness, False if there is no missingness.
"""
na_counts = df.loc[rows_to_check].isna().sum()
na_features = df.loc[rows_to_check].isna().any(axis=0).sum()
logger.debug(
f"Features (cols) with at least one NA: {na_features} / {len(df.columns)}"
)
logger.debug("Samples (rows) with at least one NA: %s", na_counts[na_counts > 0])
if na_counts.any():
return True
else:
return False
[docs]
def run_cpca_drift_correction(
df: pd.DataFrame, sample_rows, qc_rows, n_comps: int = 1
) -> pd.DataFrame:
"""
Corrects technical drift using Common Principal Components Analysis (CPCA).
Adapted from https://github.com/m-baralt/metabolomics_incident_diabetes.
Parameters
----------
df : samples as rows, features as columns
sample_rows : list of sample row indices
qc_rows : list of QC row indices
n_comps : number of common principal components to remove (default 1)
Returns
-------
Full input DataFrame with corrected values applied to the intensity rows
(sample_rows + qc_rows). Rows outside those arguments are returned unchanged.
"""
intensity_rows = sample_rows + qc_rows
X = df.loc[intensity_rows].values.astype(float) # shape: (n_samples, n_features)
if np.isnan(X).any():
raise ValueError("NA values present in dataset. Consider imputation first.")
sample_idx = list(range(len(sample_rows)))
qc_idx = list(range(len(sample_rows), len(intensity_rows)))
scaler = StandardScaler()
Xs = scaler.fit_transform(X) # shape: (n_samples, n_features)
cov_sample = np.cov(Xs[sample_idx], rowvar=False)
cov_qc = np.cov(Xs[qc_idx], rowvar=False)
avg_cov = (cov_sample + cov_qc) / 2
k = max(n_comps, 3)
eigenvalues, eigenvectors = np.linalg.eigh(avg_cov)
eigenvectors = eigenvectors[:, np.argsort(eigenvalues)[::-1]]
cpcs = eigenvectors[:, :k]
var_projected = np.sum((Xs @ cpcs) ** 2, axis=0)
var_cpc = np.round(var_projected / np.sum(Xs**2), 3)[:n_comps]
logger.info(
"CPC explained variance: %s",
str(dict(zip([f"CPC{i+1}" for i in range(n_comps)], var_cpc))),
)
W = cpcs[:, :n_comps]
Xs_corrected = Xs - Xs @ W @ W.T
X_corrected = scaler.inverse_transform(
Xs_corrected
) # shape: (n_samples, n_features)
df_out = df.astype(float)
df_out.loc[intensity_rows] = X_corrected
return df_out
[docs]
def cpca_centroid(
df: pd.DataFrame,
sample_rows, # list of row indices, OR dict {group_name: [row indices]}
qc_rows: list,
log_transform: bool = True,
):
# Normalise sample_rows to a dict
if isinstance(sample_rows, list):
sample_groups = {"Samples": sample_rows}
else:
sample_groups = sample_rows
# Build ordered row + label arrays
all_rows, labels = [], []
for group, rows in sample_groups.items():
for r in rows:
if r in df.index:
all_rows.append(r)
labels.append(group)
for r in qc_rows:
if r in df.index:
all_rows.append(r)
labels.append("QC")
# Coerce to numeric, drop features with any NaN
X = df.loc[all_rows].apply(pd.to_numeric, errors="coerce").dropna(axis=1)
if log_transform:
X = np.log1p(X.clip(lower=0))
X_scaled = StandardScaler().fit_transform(X.values.astype(float))
pca = PCA(n_components=2)
coords = pca.fit_transform(X_scaled)
# define qc points in coords
for group in list(sample_groups) + ["QC"]:
mask = [_label == group for _label in labels]
qc_points = coords[mask]
# Calculate centroid
length = qc_points.shape[0]
sum_x = np.sum(qc_points[:, 0])
sum_y = np.sum(qc_points[:, 1])
centroid = sum_x / length, sum_y / length
distances = [math.dist(point, centroid) for point in qc_points]
total_distance = sum(distances)
avg_distance = total_distance / length
logger.debug(
f"The average distance of the points to the centroid is {avg_distance:.3f}."
)
return avg_distance