chromatography-rt-prediction / revision /scripts /generate_revision_assets.py
AI4deeperScience's picture
Add files using upload-large-folder tool
6cf9dac verified
Raw
History Blame Contribute Delete
19 kB
"""Generate reviewer-requested, source-data-backed revision figures."""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
import numpy as np
import pandas as pd
from rdkit import Chem
from rdkit.Chem import Crippen, Descriptors, rdMolDescriptors
STRATEGIES = ("canonical_grouped", "scaffold_aware")
SEEDS = (123456, 123457, 123458)
STRATEGY_LABELS = {
"canonical_grouped": "Identity-grouped",
"scaffold_aware": "Scaffold-aware",
}
COLORS = {
"canonical_grouped": "#3B6FB6",
"scaffold_aware": "#D97936",
}
def _set_style() -> None:
plt.rcParams.update(
{
"font.family": "DejaVu Sans",
"font.size": 8.5,
"axes.labelsize": 9,
"axes.titlesize": 9,
"legend.fontsize": 7.5,
"xtick.labelsize": 7.5,
"ytick.labelsize": 7.5,
"axes.spines.top": False,
"axes.spines.right": False,
"savefig.dpi": 300,
"pdf.fonttype": 42,
"ps.fonttype": 42,
}
)
def _panel_label(axis: plt.Axes, label: str) -> None:
axis.text(
-0.13,
1.06,
label,
transform=axis.transAxes,
fontsize=11,
fontweight="bold",
va="top",
)
def _save_figure(figure: plt.Figure, output_dir: Path, name: str) -> None:
figure.savefig(output_dir / f"{name}.pdf", bbox_inches="tight")
figure.savefig(output_dir / f"{name}.png", bbox_inches="tight", dpi=300)
plt.close(figure)
def _distribution_table(values: np.ndarray, property_name: str, bins: int = 8) -> pd.DataFrame:
counts, edges = np.histogram(np.asarray(values, dtype=float), bins=bins)
percentages = counts.astype(float) / counts.sum() * 100.0
return pd.DataFrame(
{
"property": property_name,
"bin_lower": edges[:-1],
"bin_upper": edges[1:],
"bin_center": (edges[:-1] + edges[1:]) / 2.0,
"count": counts,
"percentage": percentages,
}
)
def _molecular_properties(data: pd.DataFrame) -> pd.DataFrame:
rows = []
for row_index, smiles in enumerate(data["SMILES"].astype(str)):
molecule = Chem.MolFromSmiles(smiles)
if molecule is None:
raise ValueError(f"Invalid SMILES at input row {row_index}: {smiles}")
rows.append(
{
"MW": Descriptors.MolWt(molecule),
"LogP": Crippen.MolLogP(molecule),
"TPSA": rdMolDescriptors.CalcTPSA(molecule),
"RT": float(data.iloc[row_index]["RT"]),
}
)
return pd.DataFrame(rows)
def _toc_graphic(output_dir: Path) -> None:
"""Draw a result-neutral TOC workflow synchronized with the revised scope."""
figure, axis = plt.subplots(figsize=(3.25, 1.75))
axis.set_xlim(0, 10)
axis.set_ylim(0, 4)
axis.axis("off")
def box(
x: float,
y: float,
width: float,
height: float,
text: str,
color: str,
fontsize: float = 6.2,
) -> None:
patch = FancyBboxPatch(
(x, y),
width,
height,
boxstyle="round,pad=0.08,rounding_size=0.12",
linewidth=1.0,
edgecolor=color,
facecolor=color + "20",
)
axis.add_patch(patch)
axis.text(
x + width / 2,
y + height / 2,
text,
ha="center",
va="center",
fontsize=fontsize,
fontweight="bold",
color="#233447",
)
def arrow(start: tuple[float, float], end: tuple[float, float]) -> None:
axis.add_patch(
FancyArrowPatch(
start,
end,
arrowstyle="-|>",
mutation_scale=12,
linewidth=1.2,
color="#536878",
)
)
box(0.2, 2.35, 1.55, 0.75, "Molecular\nstructure", "#3B6FB6", 5.8)
box(0.2, 0.85, 1.55, 0.75, "Laboratory\nlabel", "#7A6AA6", 5.8)
box(2.35, 2.7, 1.35, 0.65, "GAT", "#3B6FB6")
box(2.35, 1.75, 1.35, 0.65, "GCN", "#3B6FB6")
box(2.35, 0.8, 1.35, 0.65, "FPNN", "#3B6FB6")
box(4.55, 1.55, 2.15, 1.05, "OOF base\npredictions", "#D97936", 5.1)
box(7.25, 1.55, 1.45, 1.05, "ExtraTrees\nstack", "#D97936", 5.4)
box(9.15, 1.55, 0.65, 1.05, "RT", "#4A8C67")
for y in (3.02, 2.07, 1.12):
arrow((1.75, 2.72), (2.30, y))
arrow((1.75, 1.22), (2.30, y))
arrow((3.70, y), (4.50, 2.07))
arrow((6.70, 2.07), (7.20, 2.07))
arrow((8.70, 2.07), (9.10, 2.07))
axis.text(
5.0,
0.20,
"Identity-grouped and scaffold-aware evaluation\nwithin 23 represented laboratories",
ha="center",
va="center",
fontsize=6.0,
color="#36454F",
)
_save_figure(figure, output_dir, "toc_graphic")
def _figure_performance(summary: pd.DataFrame, output_dir: Path, source_dir: Path) -> None:
model_order = ("gat", "gcn", "fpnn", "stack_all_plus_descriptors")
labels = ("GAT", "GCN", "FPNN", "Full stack")
source = summary.loc[summary["model"].isin(model_order)].copy()
source["model"] = pd.Categorical(source["model"], model_order, ordered=True)
source = source.sort_values(["strategy", "model"])
source.to_csv(source_dir / "figure1_performance.csv", index=False)
figure, axes = plt.subplots(1, 3, figsize=(7.2, 2.45))
x = np.arange(len(model_order), dtype=float)
width = 0.36
for strategy_index, strategy in enumerate(STRATEGIES):
subset = source.loc[source["strategy"] == strategy].set_index("model").reindex(model_order)
offset = (strategy_index - 0.5) * width
for axis, metric, ylabel in zip(
axes,
("r2", "mae", "rmse"),
("$R^2$", "MAE (min)", "RMSE (min)"),
):
axis.bar(
x + offset,
subset[f"{metric}_mean"],
width,
yerr=subset[f"{metric}_sd"],
color=COLORS[strategy],
alpha=0.9,
capsize=2,
label=STRATEGY_LABELS[strategy],
)
axis.set_ylabel(ylabel)
axis.set_xticks(x, labels, rotation=25, ha="right")
axis.grid(axis="y", color="#D9D9D9", linewidth=0.6, alpha=0.8)
handles, legend_labels = axes[0].get_legend_handles_labels()
figure.legend(
handles,
legend_labels,
frameon=False,
loc="lower center",
bbox_to_anchor=(0.5, 0.005),
ncol=2,
)
for axis, label in zip(axes, "ABC"):
_panel_label(axis, label)
figure.tight_layout(rect=(0.0, 0.12, 1.0, 1.0), w_pad=1.1)
_save_figure(figure, output_dir, "figure1_performance")
def _figure_property_distributions(data: pd.DataFrame, output_dir: Path, source_dir: Path) -> None:
properties = _molecular_properties(data)
distribution = pd.concat(
[_distribution_table(properties[column].to_numpy(), column) for column in properties],
ignore_index=True,
)
distribution.to_csv(source_dir / "figure2_property_distributions.csv", index=False)
labels = {
"MW": "Molecular weight (Da)",
"LogP": "RDKit LogP",
"TPSA": "TPSA ($\AA^2$)",
"RT": "Retention time (min)",
}
figure, axes = plt.subplots(2, 2, figsize=(7.2, 5.0))
for axis, column, panel in zip(axes.flat, properties.columns, "ABCD"):
subset = distribution.loc[distribution["property"] == column]
widths = subset["bin_upper"].to_numpy() - subset["bin_lower"].to_numpy()
axis.bar(
subset["bin_center"],
subset["percentage"],
width=widths * 0.92,
color="#4F81BD",
edgecolor="white",
linewidth=0.5,
)
axis.set_xlabel(labels[column])
axis.set_ylabel("Observations (%)")
axis.grid(axis="y", color="#D9D9D9", linewidth=0.6, alpha=0.8)
_panel_label(axis, panel)
figure.tight_layout(h_pad=1.4, w_pad=1.3)
_save_figure(figure, output_dir, "figure2_property_distributions")
def _figure_ablations(
neural_summary: pd.DataFrame,
classical_summary: pd.DataFrame,
output_dir: Path,
source_dir: Path,
) -> None:
neural_models = (
"arithmetic_mean",
"stack_all_base_only",
"stack_all_plus_descriptors",
"stack_gat_gcn",
"stack_gat_fpnn",
"stack_gcn_fpnn",
)
classical_models = (
"lab_median",
"descriptor_only_ridge",
"fingerprint_no_lab_et",
"fingerprint_no_lab_plus_lab_affine",
"fingerprint_only_et",
"fingerprint_plus_descriptors_et",
)
neural = neural_summary.loc[neural_summary["model"].isin(neural_models)].copy()
neural["family"] = "Neural-stack ablation"
classical = classical_summary.loc[classical_summary["model"].isin(classical_models)].copy()
classical["family"] = "Fixed classical baseline"
source = pd.concat([neural, classical], ignore_index=True)
source.to_csv(source_dir / "figure3_ablations.csv", index=False)
display = {
"arithmetic_mean": "Arithmetic mean",
"stack_all_base_only": "3-base stack",
"stack_all_plus_descriptors": "3-base + descriptors",
"stack_gat_gcn": "GAT + GCN",
"stack_gat_fpnn": "GAT + FPNN",
"stack_gcn_fpnn": "GCN + FPNN",
"lab_median": "Lab median",
"descriptor_only_ridge": "Descriptors + lab ridge",
"fingerprint_no_lab_et": "Morgan ET, no lab",
"fingerprint_no_lab_plus_lab_affine": "Morgan ET + lab affine",
"fingerprint_only_et": "Morgan + one-hot lab ET",
"fingerprint_plus_descriptors_et": "Morgan + descriptors + lab ET",
}
figure, axes = plt.subplots(1, 2, figsize=(7.2, 4.9))
for axis, (family, model_order), panel in zip(
axes,
(("Neural-stack ablation", neural_models), ("Fixed classical baseline", classical_models)),
"AB",
):
y = np.arange(len(model_order), dtype=float)
height = 0.36
for strategy_index, strategy in enumerate(STRATEGIES):
subset = source.loc[
(source["family"] == family) & (source["strategy"] == strategy)
].set_index("model").reindex(model_order)
offset = (strategy_index - 0.5) * height
axis.barh(
y + offset,
subset["mae_mean"],
height,
xerr=subset["mae_sd"],
color=COLORS[strategy],
capsize=2,
label=STRATEGY_LABELS[strategy],
)
axis.set_yticks(y, [display[name] for name in model_order])
axis.invert_yaxis()
axis.set_xlabel("MAE (min; mean $\pm$ SD)")
axis.grid(axis="x", color="#D9D9D9", linewidth=0.6, alpha=0.8)
axis.set_title(family)
_panel_label(axis, panel)
handles, legend_labels = axes[0].get_legend_handles_labels()
figure.legend(
handles,
legend_labels,
frameon=False,
loc="lower center",
bbox_to_anchor=(0.5, 0.005),
ncol=2,
)
figure.tight_layout(rect=(0.0, 0.08, 1.0, 1.0), w_pad=1.8)
_save_figure(figure, output_dir, "figure3_ablations")
def _collect_predictions(artifacts_root: Path) -> pd.DataFrame:
frames = []
for strategy in STRATEGIES:
for seed in SEEDS:
path = artifacts_root / strategy / f"seed_{seed}" / "neural_stack" / "test_predictions.csv"
if not path.is_file():
raise FileNotFoundError(f"Incomplete prediction matrix: {path}")
frame = pd.read_csv(path)
frame.insert(0, "seed", seed)
frame.insert(0, "strategy", strategy)
frames.append(frame)
return pd.concat(frames, ignore_index=True)
def _figure_diagnostics(
predictions: pd.DataFrame,
per_lab: pd.DataFrame,
output_dir: Path,
source_dir: Path,
) -> None:
predictions = predictions.copy()
predictions["absolute_error"] = np.abs(
predictions["prediction_stack_all_plus_descriptors"] - predictions["RT"]
)
predictions["similarity_bin"] = pd.cut(
predictions["maximum_development_tanimoto"],
bins=[0.0, 0.2, 0.4, 0.6, 0.8, 1.000001],
right=False,
include_lowest=True,
).astype(str)
predictions["rt_quartile"] = predictions.groupby(["strategy", "seed"])["RT"].transform(
lambda values: pd.qcut(values, q=4, labels=("Q1", "Q2", "Q3", "Q4"), duplicates="drop")
)
similarity_bins = (
predictions.groupby(["strategy", "similarity_bin"], observed=False)
.agg(n=("absolute_error", "size"), mae=("absolute_error", "mean"))
.reset_index()
)
rt_bins = (
predictions.groupby(["strategy", "rt_quartile"], observed=False)
.agg(n=("absolute_error", "size"), mae=("absolute_error", "mean"))
.reset_index()
)
lab_primary = per_lab.loc[per_lab["model"] == "stack_all_plus_descriptors"].copy()
lab_summary = (
lab_primary.groupby(["strategy", "Lab"])
.agg(n=("n", "sum"), mae=("mae", "mean"), mae_sd=("mae", "std"))
.reset_index()
)
predictions.to_csv(source_dir / "figure4_predictions.csv", index=False)
similarity_bins.to_csv(source_dir / "figure4_similarity_bins.csv", index=False)
rt_bins.to_csv(source_dir / "figure4_rt_quartiles.csv", index=False)
lab_summary.to_csv(source_dir / "figure4_per_lab.csv", index=False)
figure, axes = plt.subplots(2, 2, figsize=(7.2, 5.6))
canonical = predictions.loc[predictions["strategy"] == "canonical_grouped"]
axes[0, 0].scatter(
canonical["maximum_development_tanimoto"],
canonical["absolute_error"],
s=10,
alpha=0.28,
color=COLORS["canonical_grouped"],
edgecolors="none",
)
axes[0, 0].set_xlabel("Maximum development-set Tanimoto")
axes[0, 0].set_ylabel("Absolute error (min)")
similarity_order = list(similarity_bins["similarity_bin"].drop_duplicates())
x = np.arange(len(similarity_order), dtype=float)
width = 0.36
for strategy_index, strategy in enumerate(STRATEGIES):
subset = similarity_bins.loc[similarity_bins["strategy"] == strategy].set_index(
"similarity_bin"
).reindex(similarity_order)
axes[0, 1].bar(
x + (strategy_index - 0.5) * width,
subset["mae"],
width,
color=COLORS[strategy],
label=STRATEGY_LABELS[strategy],
)
axes[0, 1].set_xticks(x, similarity_order, rotation=25, ha="right")
axes[0, 1].set_xlabel("Maximum Tanimoto bin")
axes[0, 1].set_ylabel("MAE (min)")
axes[0, 1].legend(frameon=False)
canonical_labs = lab_summary.loc[lab_summary["strategy"] == "canonical_grouped"].sort_values(
"mae", ascending=True
)
axes[1, 0].barh(
np.arange(len(canonical_labs)),
canonical_labs["mae"],
color=COLORS["canonical_grouped"],
)
axes[1, 0].set_yticks(np.arange(len(canonical_labs)), canonical_labs["Lab"])
axes[1, 0].set_xlabel("Identity-grouped MAE (min)")
axes[1, 0].tick_params(axis="y", labelsize=5.8)
quartile_order = ("Q1", "Q2", "Q3", "Q4")
x = np.arange(4, dtype=float)
for strategy_index, strategy in enumerate(STRATEGIES):
subset = rt_bins.loc[rt_bins["strategy"] == strategy].set_index("rt_quartile").reindex(
quartile_order
)
axes[1, 1].bar(
x + (strategy_index - 0.5) * width,
subset["mae"],
width,
color=COLORS[strategy],
label=STRATEGY_LABELS[strategy],
)
axes[1, 1].set_xticks(x, quartile_order)
axes[1, 1].set_xlabel("Within-run experimental RT quartile")
axes[1, 1].set_ylabel("MAE (min)")
for axis, panel in zip(axes.flat, "ABCD"):
axis.grid(axis="both", color="#D9D9D9", linewidth=0.5, alpha=0.7)
_panel_label(axis, panel)
figure.tight_layout(h_pad=1.4, w_pad=1.2)
_save_figure(figure, output_dir, "figure4_diagnostics")
def _write_manifest(output_dir: Path) -> None:
rows = []
for path in sorted(output_dir.rglob("*")):
if path.is_file() and path.name != "FIGURE_MANIFEST.csv":
rows.append(
{
"relative_path": path.relative_to(output_dir).as_posix(),
"bytes": path.stat().st_size,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
)
pd.DataFrame(rows).to_csv(output_dir / "FIGURE_MANIFEST.csv", index=False)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--data", required=True)
parser.add_argument("--artifacts-root", required=True)
parser.add_argument("--output-dir", required=True)
args = parser.parse_args()
data_path = Path(args.data).resolve()
artifacts_root = Path(args.artifacts_root).resolve()
output_dir = Path(args.output_dir).resolve()
if output_dir.exists() and any(output_dir.iterdir()):
raise FileExistsError(f"Refusing nonempty figure output directory: {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
source_dir = output_dir / "source_data"
source_dir.mkdir()
_set_style()
data = pd.read_csv(data_path)
summary_dir = artifacts_root / "summary"
neural_summary = pd.read_csv(summary_dir / "neural_metrics_aggregate.csv")
classical_summary = pd.read_csv(summary_dir / "classical_metrics_aggregate.csv")
per_lab = pd.read_csv(summary_dir / "neural_per_lab_by_run.csv")
predictions = _collect_predictions(artifacts_root)
_figure_performance(neural_summary, output_dir, source_dir)
_figure_property_distributions(data, output_dir, source_dir)
_figure_ablations(neural_summary, classical_summary, output_dir, source_dir)
_figure_diagnostics(predictions, per_lab, output_dir, source_dir)
_toc_graphic(output_dir)
_write_manifest(output_dir)
print(f"Wrote revision figures and source data to: {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())