ConcPre / concrete_gnn /visualize.py
Heterogeneity2025's picture
Upload 29 files
3dc9f3b verified
Raw
History Blame Contribute Delete
10.8 kB
"""Plotting helpers for model diagnostics.
Four figures:
* :func:`plot_parity` - test-set pred vs true per target.
* :func:`plot_r2_bars` - per-target R^2 across models.
* :func:`plot_training_curves` - train + val concrete loss vs epoch.
* :func:`plot_rve_graph` - one generated 2D concrete RVE.
All functions accept a ``save_path``; if provided the figure is written and
the matplotlib ``Figure`` is also returned for further customisation.
"""
from __future__ import annotations
import os
from typing import Dict, Iterable, List, Optional, Sequence
import matplotlib
matplotlib.use("Agg") # safe headless backend
import matplotlib.pyplot as plt
import numpy as np
import torch
from .graph_generator import ConcreteGraph
from .schema import (
CONCRETE_TARGETS,
EDGE_TYPE_AGGREGATE_AGGREGATE,
EDGE_TYPE_ITZ,
EDGE_TYPE_MORTAR_MORTAR,
NODE_TYPE_AGGREGATE,
NODE_TYPE_MORTAR,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _ensure_dir(path: str) -> None:
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
def _r2(true: np.ndarray, pred: np.ndarray) -> float:
ss_res = float(np.sum((true - pred) ** 2))
ss_tot = float(np.sum((true - true.mean()) ** 2))
if ss_tot < 1e-20:
return float("nan")
return 1.0 - ss_res / ss_tot
# ---------------------------------------------------------------------------
# 1. Parity plots
# ---------------------------------------------------------------------------
def plot_parity(
predictions: Dict[str, np.ndarray],
truths: np.ndarray,
model_order: Sequence[str],
save_path: Optional[str] = None,
target_names: Sequence[str] = CONCRETE_TARGETS,
):
"""One subplot per target, one row per model.
Parameters
----------
predictions: dict
Mapping ``model_name -> (N, T)`` test predictions (numpy).
truths: ndarray
Test ground-truth array of shape ``(N, T)``.
model_order: sequence of str
Which models (and in which order) to draw.
"""
n_models = len(model_order)
n_targets = len(target_names)
fig, axes = plt.subplots(
n_models,
n_targets,
figsize=(2.6 * n_targets, 2.6 * n_models),
squeeze=False,
)
for i, name in enumerate(model_order):
pred = predictions[name]
for t in range(n_targets):
ax = axes[i, t]
y_true = truths[:, t]
y_pred = pred[:, t]
r2 = _r2(y_true, y_pred)
lo = float(min(y_true.min(), y_pred.min()))
hi = float(max(y_true.max(), y_pred.max()))
span = hi - lo if hi > lo else max(1.0, abs(hi))
lo -= 0.05 * span
hi += 0.05 * span
ax.plot([lo, hi], [lo, hi], "k--", lw=0.8, alpha=0.6)
ax.scatter(y_true, y_pred, s=22, alpha=0.7, edgecolor="none")
ax.set_xlim(lo, hi)
ax.set_ylim(lo, hi)
ax.set_aspect("equal", adjustable="box")
ax.tick_params(labelsize=7)
ax.text(
0.04,
0.93,
f"$R^2$={r2:.2f}",
transform=ax.transAxes,
fontsize=8,
va="top",
)
if i == n_models - 1:
ax.set_xlabel(target_names[t], fontsize=8)
if t == 0:
ax.set_ylabel(f"{name}\npred", fontsize=8)
fig.suptitle("Test-set parity (true on x, predicted on y)", fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.97))
if save_path is not None:
_ensure_dir(save_path)
fig.savefig(save_path, dpi=160)
return fig
# ---------------------------------------------------------------------------
# 2. Per-target R^2 bars
# ---------------------------------------------------------------------------
def plot_r2_bars(
r2_by_model: Dict[str, np.ndarray],
model_order: Sequence[str],
save_path: Optional[str] = None,
target_names: Sequence[str] = CONCRETE_TARGETS,
):
n_targets = len(target_names)
n_models = len(model_order)
x = np.arange(n_targets)
width = 0.8 / max(1, n_models)
fig, ax = plt.subplots(figsize=(1.2 * n_targets + 2.0, 4.0))
colours = plt.cm.tab10(np.linspace(0, 1, max(3, n_models)))
for i, name in enumerate(model_order):
r2 = np.asarray(r2_by_model[name])
offset = (i - (n_models - 1) / 2.0) * width
ax.bar(x + offset, r2, width=width * 0.95, label=name, color=colours[i])
ax.axhline(0.0, color="k", lw=0.6, alpha=0.5)
ax.set_xticks(x)
ax.set_xticklabels(target_names, rotation=30, ha="right", fontsize=8)
ax.set_ylabel("Test-set $R^2$")
ax.set_title("Per-target $R^2$ by model")
ax.legend(fontsize=8, frameon=False)
ax.set_ylim(min(-0.2, min((r2_by_model[n].min() for n in model_order))) - 0.05, 1.05)
fig.tight_layout()
if save_path is not None:
_ensure_dir(save_path)
fig.savefig(save_path, dpi=160)
return fig
# ---------------------------------------------------------------------------
# 3. Training curves
# ---------------------------------------------------------------------------
def plot_training_curves(
histories: Dict[str, List[Dict[str, float]]],
model_order: Sequence[str],
save_path: Optional[str] = None,
):
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
train_ax, val_ax = axes
colours = plt.cm.tab10(np.linspace(0, 1, max(3, len(model_order))))
for i, name in enumerate(model_order):
history = histories[name]
epochs = np.arange(1, len(history) + 1)
train_loss = np.asarray([h["concrete"] for h in history])
train_ax.plot(epochs, train_loss, label=name, color=colours[i], lw=1.4)
if "val_concrete" in history[0]:
val_loss = np.asarray([h.get("val_concrete", np.nan) for h in history])
val_ax.plot(epochs, val_loss, label=name, color=colours[i], lw=1.4)
if np.isfinite(val_loss).any():
best_idx = int(np.nanargmin(val_loss))
val_ax.scatter(
[epochs[best_idx]],
[val_loss[best_idx]],
color=colours[i],
edgecolor="black",
zorder=5,
s=42,
)
for ax, title in zip(axes, ("train concrete loss", "val concrete loss")):
ax.set_yscale("log")
ax.set_xlabel("epoch")
ax.set_ylabel("normalised MSE")
ax.set_title(title)
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, frameon=False)
fig.suptitle("Training curves (best-val checkpoint marked)", fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.95))
if save_path is not None:
_ensure_dir(save_path)
fig.savefig(save_path, dpi=160)
return fig
# ---------------------------------------------------------------------------
# 4. Concrete RVE graph visualization
# ---------------------------------------------------------------------------
def plot_rve_graph(
graph: ConcreteGraph,
rve_size_mm: float = 150.0,
save_path: Optional[str] = None,
):
"""Draw aggregates as circles, mortar nodes as dots, edges coloured by type."""
pos = graph.pos.detach().cpu().numpy()
node_type = graph.node_type.detach().cpu().numpy()
edge_index = graph.edge_index.detach().cpu().numpy()
edge_type = graph.edge_type.detach().cpu().numpy()
x = graph.x.detach().cpu().numpy()
fig, ax = plt.subplots(figsize=(7, 7))
# Edges first (so nodes draw on top).
seen = set()
edge_styles = {
EDGE_TYPE_ITZ: dict(color="#d62728", alpha=0.6, lw=0.8, ls="-", label="ITZ"),
EDGE_TYPE_MORTAR_MORTAR: dict(
color="#7f7f7f", alpha=0.45, lw=0.6, ls="-", label="mortar-mortar"
),
EDGE_TYPE_AGGREGATE_AGGREGATE: dict(
color="#1f77b4", alpha=0.6, lw=0.7, ls=":", label="agg-agg"
),
}
legend_seen = set()
for k in range(edge_index.shape[1]):
a = int(edge_index[0, k])
b = int(edge_index[1, k])
if a == b:
continue
key = (min(a, b), max(a, b))
if key in seen:
continue
seen.add(key)
style = edge_styles[int(edge_type[k])]
label = style["label"] if int(edge_type[k]) not in legend_seen else None
legend_seen.add(int(edge_type[k]))
ax.plot(
[pos[a, 0], pos[b, 0]],
[pos[a, 1], pos[b, 1]],
color=style["color"],
alpha=style["alpha"],
lw=style["lw"],
ls=style["ls"],
label=label,
)
# Aggregate circles (diameter is the first feature channel).
for i in range(pos.shape[0]):
if int(node_type[i]) == NODE_TYPE_AGGREGATE:
diameter = float(x[i, 0])
radius = max(1.0, 0.5 * diameter)
ax.add_patch(
plt.Circle(
(pos[i, 0], pos[i, 1]),
radius=radius,
facecolor="#bdbdbd",
edgecolor="black",
lw=0.6,
alpha=0.85,
zorder=2,
)
)
mortar_pts = pos[node_type == NODE_TYPE_MORTAR]
ax.scatter(
mortar_pts[:, 0],
mortar_pts[:, 1],
s=18,
c="#2ca02c",
edgecolor="black",
lw=0.3,
zorder=3,
label="mortar patch",
)
ax.set_xlim(0, rve_size_mm)
ax.set_ylim(0, rve_size_mm)
ax.set_aspect("equal")
ax.set_xlabel("x [mm]")
ax.set_ylabel("y [mm]")
ax.set_title("Generated 2D concrete RVE (aggregates + mortar + edge types)")
ax.legend(fontsize=8, loc="upper right", frameon=True)
fig.tight_layout()
if save_path is not None:
_ensure_dir(save_path)
fig.savefig(save_path, dpi=160)
return fig
# ---------------------------------------------------------------------------
# Prediction collection helper
# ---------------------------------------------------------------------------
@torch.no_grad()
def collect_predictions(
model: torch.nn.Module,
loader: Iterable,
device: torch.device,
) -> Dict[str, np.ndarray]:
"""Return ``{"pred": (N, T), "true": (N, T)}`` arrays on CPU."""
model.eval()
preds, trues = [], []
for batch in loader:
batch = batch.to(device)
out = model(batch)
preds.append(out["concrete_pred"].cpu())
trues.append(batch.concrete_target.cpu())
return {
"pred": torch.cat(preds, dim=0).numpy(),
"true": torch.cat(trues, dim=0).numpy(),
}