PlantPerturb-Small / scripts /train_pls_model.py
aungkomyint's picture
Publish research draft and reproducible PCA-ridge benchmark
18410f0 verified
Raw History Blame Contribute Delete
8.6 kB
"""Train a supervised Partial Least Squares drought-response model."""
from pathlib import Path
import json
import joblib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from scipy.stats import pearsonr, spearmanr
from sklearn.cross_decomposition import PLSRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = PROJECT_ROOT / "data" / "processed"
RESULTS_DIR = PROJECT_ROOT / "results" / "pls"
FIGURES_DIR = RESULTS_DIR / "figures"
MODEL_DIR = PROJECT_ROOT / "models"
COMPONENT_GRID = [1, 2, 3, 5, 10]
N_INPUT_GENES = 2_000
MAX_ITERATIONS = 500
def load_data() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
expression = pd.read_csv(
DATA_DIR / "expression_log2_cpm.tsv.gz", sep="\t", index_col=0
)
groups = pd.read_csv(DATA_DIR / "sample_groups.csv").set_index("sample_group_id")
groups = groups.loc[expression.columns]
joined = expression.T.join(groups[["ecotype", "condition"]])
means = joined.groupby(["ecotype", "condition"]).mean()
control = means.xs("control", level="condition").sort_index()
drought = means.xs("drought", level="condition").sort_index()
delta = drought - control
splits = pd.read_csv(DATA_DIR / "ecotype_splits.csv").set_index("ecotype")
return control, delta, splits
def select_input_genes(control: pd.DataFrame, count: int = N_INPUT_GENES) -> pd.Index:
return control.var(axis=0).nlargest(count).index
def make_model(n_components: int) -> Pipeline:
return Pipeline(
[
("scale_inputs", StandardScaler()),
(
"pls",
PLSRegression(
n_components=n_components,
scale=False,
max_iter=MAX_ITERATIONS,
tol=1e-6,
),
),
]
)
def safe_correlations(actual: np.ndarray, predicted: np.ndarray) -> tuple[float, float]:
if np.allclose(predicted, predicted[0]) or np.allclose(actual, actual[0]):
return np.nan, np.nan
return pearsonr(actual, predicted).statistic, spearmanr(actual, predicted).statistic
def evaluate(actual: pd.DataFrame, predicted: np.ndarray, model: str) -> pd.DataFrame:
rows = []
for position, ecotype in enumerate(actual.index):
observed = actual.loc[ecotype].to_numpy()
estimate = predicted[position]
error = estimate - observed
pearson, spearman = safe_correlations(observed, estimate)
rows.append(
{
"ecotype": ecotype,
"model": model,
"mse": np.mean(error**2),
"mae": np.mean(np.abs(error)),
"pearson": pearson,
"spearman": spearman,
}
)
return pd.DataFrame(rows)
def summarize(metrics: pd.DataFrame) -> pd.DataFrame:
summary = metrics.groupby("model")[["mse", "mae", "pearson", "spearman"]].agg(
["mean", "median", "std"]
)
summary.columns = ["_".join(column) for column in summary.columns]
return summary.reset_index()
def main() -> None:
control, delta, splits = load_data()
train_ids = splits.index[splits["split"].eq("train")]
validation_ids = splits.index[splits["split"].eq("validation")]
test_ids = splits.index[splits["split"].eq("test")]
training_genes = select_input_genes(control.loc[train_ids])
validation_rows = []
for components in COMPONENT_GRID:
model = make_model(components)
model.fit(control.loc[train_ids, training_genes], delta.loc[train_ids])
prediction = model.predict(control.loc[validation_ids, training_genes])
score = evaluate(delta.loc[validation_ids], prediction, "pls")
validation_rows.append(
{
"n_components": components,
"n_input_genes": len(training_genes),
"validation_mse": score["mse"].mean(),
"validation_mae": score["mae"].mean(),
"validation_pearson": score["pearson"].mean(),
"validation_spearman": score["spearman"].mean(),
"maximum_iterations_used": int(
np.max(model.named_steps["pls"].n_iter_)
),
}
)
validation = pd.DataFrame(validation_rows).sort_values(
["validation_mse", "validation_mae", "n_components"]
)
selected_components = int(validation.iloc[0]["n_components"])
# Re-select variable inputs using all development ecotypes after the number
# of PLS components has been fixed on validation data.
development_ids = train_ids.append(validation_ids)
development_genes = select_input_genes(control.loc[development_ids])
final_model = make_model(selected_components)
final_model.fit(
control.loc[development_ids, development_genes],
delta.loc[development_ids],
)
pls_prediction = final_model.predict(control.loc[test_ids, development_genes])
development_mean = delta.loc[development_ids].mean(axis=0).to_numpy()
mean_prediction = np.tile(development_mean, (len(test_ids), 1))
zero_prediction = np.zeros_like(pls_prediction)
pca_ridge_metrics = pd.read_csv(
PROJECT_ROOT / "results" / "reduced_rank_ridge" / "test_metrics_by_ecotype.csv"
).loc[lambda frame: frame["model"].eq("pca_ridge")]
test_metrics = pd.concat(
[
evaluate(delta.loc[test_ids], zero_prediction, "zero_change"),
evaluate(delta.loc[test_ids], mean_prediction, "development_mean"),
pca_ridge_metrics,
evaluate(delta.loc[test_ids], pls_prediction, "pls"),
],
ignore_index=True,
)
test_summary = summarize(test_metrics)
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
FIGURES_DIR.mkdir(parents=True, exist_ok=True)
MODEL_DIR.mkdir(parents=True, exist_ok=True)
validation.to_csv(RESULTS_DIR / "validation_components.csv", index=False)
test_metrics.to_csv(RESULTS_DIR / "test_metrics_by_ecotype.csv", index=False)
test_summary.to_csv(RESULTS_DIR / "test_summary.csv", index=False)
pd.Series(development_genes, name="gene_id").to_csv(
RESULTS_DIR / "selected_input_genes.csv", index=False
)
joblib.dump(
{"input_genes": development_genes, "pipeline": final_model},
MODEL_DIR / "pls_model.joblib",
)
(MODEL_DIR / "pls_model.json").write_text(
json.dumps(
{
"model": "Partial Least Squares regression",
"selected_components": selected_components,
"selected_input_genes": len(development_genes),
"input_gene_selection": "highest variance in development controls",
"selection_metric": "mean validation MSE across ecotypes",
"development_ecotypes": len(development_ids),
"test_ecotypes": len(test_ids),
"maximum_iterations_used_final_fit": int(
np.max(final_model.named_steps["pls"].n_iter_)
),
},
indent=2,
),
encoding="utf-8",
)
sns.set_theme(style="whitegrid", context="notebook")
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.3))
ordered_validation = validation.sort_values("n_components")
sns.lineplot(
data=ordered_validation,
x="n_components",
y="validation_mse",
marker="o",
color="#356859",
ax=axes[0],
)
axes[0].set(
title="PLS component selection",
xlabel="PLS components",
ylabel="Validation MSE",
)
sns.boxplot(
data=test_metrics,
x="model",
y="mae",
hue="model",
legend=False,
palette=["#9AA4A6", "#3D6FB4", "#C58A2B", "#7A5195"],
ax=axes[1],
)
axes[1].set(title="Supervised-model test error", xlabel="Model", ylabel="MAE")
axes[1].tick_params(axis="x", rotation=18)
fig.tight_layout()
fig.savefig(FIGURES_DIR / "pls_model_comparison.png", dpi=220)
plt.close(fig)
print("Selected using validation data:")
print(f"Input genes: {N_INPUT_GENES}")
print(f"PLS components: {selected_components}")
print(f"Validation MSE: {validation.iloc[0]['validation_mse']:.6f}")
print("\nFinal test means:")
print(
test_summary[["model", "mse_mean", "mae_mean", "pearson_mean", "spearman_mean"]]
.to_string(index=False)
)
if __name__ == "__main__":
main()