Spaces:
Running
Running
| """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 | |
| # --------------------------------------------------------------------------- | |
| 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(), | |
| } | |