Files
PSO/test/post_training_pso_ensemble.py
T
jung-geun 813433000a feat: modernize PSO and add convergence research
Migrate the package and examples to the tensor-native PyTorch implementation, add benchmark evidence, and add the guarded post-training convergence protocol with TensorBoard progress monitoring and hash-verified recovery.

Constraint: Preserve one-shot official-test sealing and auditable research artifacts

Rejected: Commit local .omc runs and downloaded datasets | multi-gigabyte runtime state is machine-local

Confidence: high

Scope-risk: broad

Not-tested: Production CUDA run on pieroot-server
2026-09-07 22:03:25 +09:00

1752 lines
67 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Post-Training PSO Ensemble Study Runner
Determines whether PSO is useful and efficient after ordinary backpropagation by
optimizing prediction-space ensemble weights for independently Adam-trained CNNs.
Key Invariants:
1. Development source: only each dataset's official train=True 60,000 examples.
2. Stratified split: 50,000 search / 10,000 validation with split seed 20260904.
3. Normalization: mean and std fitted on search split only (unrounded).
4. Model: CompactCNN (9,098 parameters).
5. Pool seeds: 201, 202, 203, 204, 205.
6. Baseline single_50e: seed 201 captured at epoch 10 and continued to epoch 50.
7. Optimization methods: reference_single_10e, best_single_10e, single_50e,
uniform_ensemble, uniform_temperature, slsqp_weights, pso_weights.
8. Official test dataset (train=False) loaded only after development gates pass.
9. Trained models retained in memory and reused for official test without retraining.
10. Atomic writes via unique same-directory temporary files + os.replace.
"""
import argparse
import copy
import hashlib
import json
import math
import os
import sys
import time
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
import scipy.optimize
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
from sklearn.model_selection import train_test_split
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# Ensure repository root is in sys.path
repo_root = Path(__file__).resolve().parent.parent
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
from pso.optimizer import Optimizer, resolve_device
PROTOCOL_VERSION = "POST-TRAINING-PSO-ENSEMBLE 1.1.0"
def sync_device(device: Optional[Union[str, torch.device]] = None):
dev = resolve_device(device)
if dev.type == "cuda":
torch.cuda.synchronize(dev)
elif dev.type == "mps" and hasattr(torch, "mps") and hasattr(torch.mps, "synchronize"):
torch.mps.synchronize()
# =====================================================================
# 1. Architecture: CompactCNN (9,098 Parameters)
# =====================================================================
class CompactCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 8, kernel_size=3, padding=1)
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2, 2)
self.flatten = nn.Flatten()
self.fc = nn.Linear(784, 10)
def forward(self, x: torch.Tensor) -> torch.Tensor:
if x.dim() == 2 and x.shape[1] == 784:
x = x.view(-1, 1, 28, 28)
out = self.pool1(self.relu1(self.conv1(x)))
out = self.pool2(self.relu2(self.conv2(out)))
out = self.flatten(out)
return self.fc(out)
def compute_model_fingerprint(model: nn.Module) -> str:
h = hashlib.sha256()
for p in model.parameters():
h.update(p.detach().cpu().numpy().tobytes())
return h.hexdigest()[:16]
# =====================================================================
# 2. PyTorch Softmax Weight Wrapper over Cached Member Probabilities
# =====================================================================
class CachedProbabilityEnsemble(nn.Module):
"""
PyTorch module parameterizing ensemble weights via a softmax vector over M member probabilities.
Exposes raw_weights parameter optimized by public pso.Optimizer.
Returns normalized log probabilities for nn.NLLLoss evaluation.
Member probabilities input has shape (N, M, K).
"""
def __init__(self, num_members: int = 5, init_weights: Optional[torch.Tensor] = None):
super().__init__()
self.num_members = num_members
if init_weights is not None:
if init_weights.shape != (num_members,):
raise ValueError(f"init_weights must have shape ({num_members},)")
self.raw_weights = nn.Parameter(init_weights.clone().float())
else:
self.raw_weights = nn.Parameter(torch.zeros(num_members, dtype=torch.float32))
def weights(self) -> torch.Tensor:
return F.softmax(self.raw_weights, dim=0)
def forward(self, member_probabilities: torch.Tensor) -> torch.Tensor:
"""
member_probabilities: canonical tensor of shape (N, M, K)
Returns log probabilities of shape (N, K)
"""
if member_probabilities.dim() != 3:
raise ValueError("member_probabilities must be a 3D tensor")
if member_probabilities.shape[1] != self.num_members:
raise ValueError(
"member_probabilities must use canonical (N, M, K) orientation "
f"with M={self.num_members}, got {tuple(member_probabilities.shape)}"
)
w = self.weights().view(1, -1, 1)
mix = torch.sum(w * member_probabilities, dim=1)
return torch.log(torch.clamp(mix, min=1e-12))
# =====================================================================
# 3. Probability Cache & Metric Utilities
# =====================================================================
def validate_probability_cache(probabilities: Union[torch.Tensor, np.ndarray]) -> bool:
if isinstance(probabilities, torch.Tensor):
arr = probabilities.detach().cpu().numpy()
else:
arr = np.asarray(probabilities)
if arr.ndim != 3:
return False
if arr.shape[0] == 0 or arr.shape[1] == 0:
return False
if arr.shape[2] < 2:
return False
if not np.all(np.isfinite(arr)):
return False
if np.any(arr < -1e-6) or np.any(arr > 1.0 + 1e-6):
return False
row_sums = arr.sum(axis=-1)
if not np.allclose(row_sums, 1.0, atol=1e-4):
return False
return True
def mixture_probabilities(
weights: Union[torch.Tensor, np.ndarray],
member_probabilities: Union[torch.Tensor, np.ndarray],
) -> Union[torch.Tensor, np.ndarray]:
if isinstance(weights, torch.Tensor):
w_np = weights.detach().cpu().numpy()
else:
w_np = np.asarray(weights, dtype=np.float64)
if not np.all(np.isfinite(w_np)):
raise ValueError("weights contain non-finite values")
if np.any(w_np < -1e-6):
raise ValueError("weights contain negative values")
w_sum = float(w_np.sum())
if w_sum <= 0:
raise ValueError("weights sum to zero or negative")
if not validate_probability_cache(member_probabilities):
raise ValueError("member_probabilities fails validate_probability_cache")
is_torch = isinstance(member_probabilities, torch.Tensor)
if member_probabilities.shape[0] != len(w_np):
raise ValueError(
"member_probabilities must use canonical (M, N, K) orientation "
f"with M={len(w_np)}, got {tuple(member_probabilities.shape)}"
)
if is_torch:
w = torch.as_tensor(
weights,
dtype=member_probabilities.dtype,
device=member_probabilities.device,
)
w = w / w.sum()
return torch.sum(w.view(-1, 1, 1) * member_probabilities, dim=0)
w = np.asarray(weights, dtype=np.float64)
w = w / w.sum()
probs = np.asarray(member_probabilities, dtype=np.float64)
return np.sum(w[:, None, None] * probs, axis=0)
def probabilistic_metrics(
probabilities: Union[torch.Tensor, np.ndarray],
targets: Union[torch.Tensor, np.ndarray],
) -> Dict[str, float]:
if isinstance(probabilities, torch.Tensor):
probs = probabilities.detach().cpu().numpy()
else:
probs = np.asarray(probabilities, dtype=np.float64)
if isinstance(targets, torch.Tensor):
labels = targets.detach().cpu().numpy()
else:
labels = np.asarray(targets, dtype=np.int64)
if probs.ndim != 2:
raise ValueError(f"probabilities must be 2D array, got shape {probs.shape}")
N, K = probs.shape
if labels.ndim != 1 or len(labels) != N:
raise ValueError(f"targets must be 1D array of length N={N}, got shape {labels.shape}")
if not np.all(np.isfinite(probs)):
raise ValueError("probabilities contain non-finite values")
if np.any(labels < 0) or np.any(labels >= K):
raise ValueError(f"targets must contain integers in range [0, {K-1}]")
row_sums = probs.sum(axis=1)
if not np.allclose(row_sums, 1.0, atol=1e-3):
raise ValueError("probabilities rows must sum to 1.0")
preds = probs.argmax(axis=1)
acc = float((preds == labels).mean()) * 100.0
eps = 1e-12
clipped = np.clip(probs, eps, 1.0 - eps)
nll = -float(np.log(clipped[np.arange(N), labels]).mean())
y_onehot = np.zeros((N, K), dtype=np.float64)
y_onehot[np.arange(N), labels] = 1.0
brier = float(np.mean(np.sum((probs - y_onehot) ** 2, axis=1)))
n_bins = 15
bin_boundaries = np.linspace(0.0, 1.0, n_bins + 1)
confidences = probs.max(axis=1)
ece = 0.0
for i in range(n_bins):
bin_lower = bin_boundaries[i]
bin_upper = bin_boundaries[i + 1]
in_bin = (confidences > bin_lower) & (confidences <= bin_upper) if i > 0 else (confidences >= bin_lower) & (confidences <= bin_upper)
prop = in_bin.mean()
if prop > 0:
accuracy_in_bin = (preds[in_bin] == labels[in_bin]).mean()
avg_conf = confidences[in_bin].mean()
ece += np.abs(accuracy_in_bin - avg_conf) * prop
sorted_probs = np.sort(probs, axis=1)[:, ::-1]
margins = sorted_probs[:, 0] - sorted_probs[:, 1]
margin_mean = float(margins.mean())
return {
"accuracy": round(acc, 4),
"nll": round(nll, 6),
"brier": round(brier, 6),
"ece": round(float(ece), 6),
"margin": round(margin_mean, 6),
}
evaluate_probabilistic_metrics = probabilistic_metrics
def temp_scaled_probs(uniform_probs: np.ndarray, temp: float) -> np.ndarray:
eps = 1e-12
log_p = np.log(np.clip(uniform_probs, eps, 1.0))
scaled_log_p = log_p / temp
max_log_p = np.max(scaled_log_p, axis=1, keepdims=True)
exp_p = np.exp(scaled_log_p - max_log_p)
return exp_p / np.sum(exp_p, axis=1, keepdims=True)
def fit_uniform_temperature(uniform_probs: np.ndarray, targets: np.ndarray) -> Tuple[float, Dict[str, Any]]:
eval_count = 0
def obj(t: float) -> float:
nonlocal eval_count
eval_count += 1
p = temp_scaled_probs(uniform_probs, t)
return probabilistic_metrics(p, targets)["nll"]
t0 = time.perf_counter()
res = scipy.optimize.minimize_scalar(obj, bounds=(0.01, 10.0), method="bounded")
wall_t = time.perf_counter() - t0
if not res.success or not math.isfinite(res.x) or res.x <= 0:
raise RuntimeError(f"Temperature scaling optimization failed: success={res.success}, x={res.x}")
best_t = float(res.x)
best_probs = temp_scaled_probs(uniform_probs, best_t)
metrics = probabilistic_metrics(best_probs, targets)
temp_record = {
"fitted_temperature": round(best_t, 6),
"wall_time_seconds": float(wall_t),
"evaluations": int(eval_count),
"metrics": metrics,
}
return best_t, temp_record
# =====================================================================
# 4. SLSQP Solver with Analytical Simplex NLL Gradient
# =====================================================================
def simplex_nll_and_grad(
weights: np.ndarray,
member_probabilities: np.ndarray,
targets: np.ndarray,
) -> Tuple[float, np.ndarray]:
"""
Calculate validation NLL and its analytical gradient.
`member_probabilities` has one canonical orientation: (M, N, K).
"""
w = np.asarray(weights, dtype=np.float64)
probs_mnk = np.asarray(member_probabilities, dtype=np.float64)
labels = np.asarray(targets, dtype=np.int64)
if not np.all(np.isfinite(w)):
raise ValueError("Weights contain non-finite values")
if probs_mnk.ndim != 3:
raise ValueError(
f"member_probabilities must be 3D array, got {probs_mnk.ndim}D"
)
M, N, _ = probs_mnk.shape
if len(w) != M:
raise ValueError(f"Weights length {len(w)} does not match num_members M={M}")
if labels.ndim != 1 or len(labels) != N:
raise ValueError(
"member_probabilities must use canonical (M, N, K) orientation "
f"with target count N={len(labels)}, got {probs_mnk.shape}"
)
mix_p = np.sum(w[:, None, None] * probs_mnk, axis=0)
p_true = mix_p[np.arange(N), labels]
p_true_clamped = np.maximum(p_true, 1e-12)
nll = -float(np.mean(np.log(p_true_clamped)))
P_true = probs_mnk[
np.arange(M)[:, None],
np.arange(N)[None, :],
labels[None, :],
]
grad = -np.mean(P_true / p_true_clamped[None, :], axis=1)
return nll, grad
def optimize_slsqp_weights(
member_probabilities: np.ndarray,
targets: np.ndarray,
) -> Dict[str, Any]:
probs_mnk = np.asarray(member_probabilities, dtype=np.float64)
labels = np.asarray(targets, dtype=np.int64)
if probs_mnk.ndim != 3:
raise ValueError(
f"member_probabilities must be 3D array, got {probs_mnk.ndim}D"
)
M, N, _ = probs_mnk.shape
if labels.ndim != 1 or len(labels) != N:
raise ValueError(
"member_probabilities must use canonical (M, N, K) orientation "
f"with target count N={len(labels)}, got {probs_mnk.shape}"
)
w0 = np.full(M, 1.0 / M, dtype=np.float64)
bounds = [(0.0, 1.0)] * M
constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1.0, 'jac': lambda w: np.ones_like(w)}
eval_count = 0
def obj_func(w):
nonlocal eval_count
eval_count += 1
return simplex_nll_and_grad(w, probs_mnk, labels)
start_t = time.perf_counter()
res = scipy.optimize.minimize(
fun=obj_func,
x0=w0,
method="SLSQP",
jac=True,
bounds=bounds,
constraints=constraints,
options={'ftol': 1e-12, 'maxiter': 1000},
)
wall_t = time.perf_counter() - start_t
raw_w = np.maximum(res.x, 0.0)
sum_w = raw_w.sum()
norm_w = raw_w / sum_w if sum_w > 0 else np.full(M, 1.0 / M)
mix_probs = mixture_probabilities(norm_w, probs_mnk)
metrics = probabilistic_metrics(mix_probs, labels)
return {
"weights": norm_w.tolist(),
"evaluations": int(eval_count),
"wall_time_seconds": float(wall_t),
"success": bool(res.success),
"message": str(res.message),
"metrics": metrics,
}
# =====================================================================
# 5. Public PSO Optimizer Wrapper over Ensemble Weights
# =====================================================================
def run_pso_weights(
member_probabilities: Union[torch.Tensor, np.ndarray],
targets: Union[torch.Tensor, np.ndarray],
swarm_seeds: Optional[List[int]] = None,
particles: int = 30,
epochs: int = 30,
device: Optional[str] = None,
) -> Dict[str, Any]:
if swarm_seeds is None:
swarm_seeds = [301, 302, 303]
if isinstance(member_probabilities, np.ndarray):
probs_t = torch.from_numpy(member_probabilities).float()
else:
probs_t = member_probabilities.float()
if isinstance(targets, np.ndarray):
targets_t = torch.from_numpy(targets).long()
else:
targets_t = targets.long()
if device is None:
dev = resolve_device()
else:
dev = torch.device(device)
if probs_t.dim() != 3:
raise ValueError(
f"member_probabilities must be 3D tensor, got {probs_t.dim()}D"
)
M, N, _ = probs_t.shape
if targets_t.dim() != 1 or len(targets_t) != N:
raise ValueError(
"member_probabilities must use canonical (M, N, K) orientation "
f"with target count N={len(targets_t)}, got {tuple(probs_t.shape)}"
)
probs_mnk = probs_t.contiguous()
probs_nmk = probs_mnk.permute(1, 0, 2).contiguous()
N, M, K = probs_nmk.shape
probs_dev = probs_nmk.to(dev)
targets_dev = targets_t.to(dev)
queries_per_seed = particles * epochs
sample_evaluations_per_seed = particles * epochs * N
per_seed_runs = []
best_nll = float('inf')
selected_seed = swarm_seeds[0]
selected_weights = [1.0 / M] * M
selected_metrics: Dict[str, float] = {}
for seed in swarm_seeds:
model = CachedProbabilityEnsemble(num_members=M).to(dev)
nn.init.zeros_(model.raw_weights)
loss_fn = nn.NLLLoss()
opt = Optimizer(
model=model,
loss=loss_fn,
task="multiclass",
method="constriction",
evaluation="full",
n_particles=particles,
particle_min=-4.0,
particle_max=4.0,
boundary_strategy="reflect",
velocity_limit_ratio=0.1,
initialization="model_noise",
initial_position_noise=0.0,
seed=seed,
device=dev,
)
sync_device(dev)
start_t = time.perf_counter()
opt.fit(probs_dev, targets_dev, epochs=epochs, renewal="loss")
sync_device(dev)
wall_t = time.perf_counter() - start_t
# Retrieve optimized weights from opt.get_best_model().weights(), never opt.model
best_model = opt.get_best_model()
weights_tensor = best_model.weights().detach().cpu()
weights_list = weights_tensor.numpy().tolist()
mix_p = mixture_probabilities(weights_tensor, probs_mnk)
metrics = probabilistic_metrics(mix_p, targets_t)
run_rec = {
"seed": seed,
"queries": queries_per_seed,
"sample_evaluations": sample_evaluations_per_seed,
"wall_time_seconds": float(wall_t),
"metrics": metrics,
"weights": weights_list,
}
per_seed_runs.append(run_rec)
if metrics["nll"] < best_nll:
best_nll = metrics["nll"]
selected_seed = seed
selected_weights = weights_list
selected_metrics = metrics
wall_times = [r["wall_time_seconds"] for r in per_seed_runs]
median_wall = float(np.median(wall_times))
total_wall = float(np.sum(wall_times))
return {
"per_seed_runs": per_seed_runs,
"selected_seed": selected_seed,
"selected_weights": selected_weights,
"metrics": selected_metrics,
"queries_per_seed": queries_per_seed,
"sample_evaluations_per_seed": sample_evaluations_per_seed,
"total_queries": queries_per_seed * len(swarm_seeds),
"total_sample_evaluations": sample_evaluations_per_seed * len(swarm_seeds),
"median_one_seed_wall_time_seconds": median_wall,
"total_wall_time_seconds": total_wall,
}
# =====================================================================
# 6. Data Preparation (Strict Train=True Only During Development)
# =====================================================================
def prepare_dataset_splits(
dataset_name: str,
split_seed: int = 20260904,
cache_dir: Optional[Path] = None,
) -> Tuple[
torch.Tensor, torch.Tensor,
torch.Tensor, torch.Tensor,
Dict[str, Any]
]:
"""
Constructs 50,000 search split and 10,000 validation split using exclusively train=True.
Fits mean and std on search split only (unrounded). Never constructs train=False.
"""
if cache_dir is None:
cache_dir = Path("result/cache")
cache_dir.mkdir(parents=True, exist_ok=True)
ds_lower = dataset_name.lower()
if ds_lower == "mnist":
from torchvision.datasets import MNIST
raw_train = MNIST(root=str(cache_dir), train=True, download=True)
canonical_name = "MNIST"
elif ds_lower in ("fashion_mnist", "fashion"):
from torchvision.datasets import FashionMNIST
raw_train = FashionMNIST(root=str(cache_dir), train=True, download=True)
canonical_name = "FashionMNIST"
else:
raise ValueError(f"Unsupported dataset name: '{dataset_name}'")
x_train_raw = raw_train.data.float() / 255.0 # (60000, 28, 28)
y_train_raw = raw_train.targets.long()
indices = np.arange(len(y_train_raw))
search_idx, val_idx = train_test_split(
indices,
train_size=50000,
test_size=10000,
stratify=y_train_raw.numpy(),
random_state=split_seed,
)
x_search_raw = x_train_raw[search_idx]
y_search = y_train_raw[search_idx]
x_val_raw = x_train_raw[val_idx]
y_val = y_train_raw[val_idx]
mean_val = float(x_search_raw.mean())
std_val = float(x_search_raw.std())
x_search_norm = ((x_search_raw - mean_val) / std_val).unsqueeze(1)
x_val_norm = ((x_val_raw - mean_val) / std_val).unsqueeze(1)
h_data = hashlib.sha256()
for t in (x_search_norm, x_val_norm, y_search, y_val):
h_data.update(t.detach().cpu().numpy().tobytes())
data_fp = h_data.hexdigest()[:16]
h_split = hashlib.sha256()
h_split.update(search_idx.tobytes())
h_split.update(val_idx.tobytes())
split_fp = h_split.hexdigest()[:16]
provenance = {
"dataset_name": canonical_name,
"split_seed": split_seed,
"search_samples": 50000,
"validation_samples": 10000,
"normalization": {"mean": mean_val, "std": std_val},
"data_fingerprint": data_fp,
"split_fingerprint": split_fp,
}
return x_search_norm, y_search, x_val_norm, y_val, provenance
def load_official_test_data(
dataset_name: str,
mean_val: float,
std_val: float,
cache_dir: Optional[Path] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Deferred test loader called exclusively after all development gates pass.
Uses exact unrounded normalization parameters fitted on search split.
"""
if cache_dir is None:
cache_dir = Path("result/cache")
ds_lower = dataset_name.lower()
if ds_lower == "mnist":
from torchvision.datasets import MNIST
raw_test = MNIST(root=str(cache_dir), train=False, download=True)
elif ds_lower in ("fashion_mnist", "fashion"):
from torchvision.datasets import FashionMNIST
raw_test = FashionMNIST(root=str(cache_dir), train=False, download=True)
else:
raise ValueError(f"Unsupported dataset name: '{dataset_name}'")
x_test_raw = raw_test.data.float() / 255.0
y_test = raw_test.targets.long()
x_test_norm = ((x_test_raw - mean_val) / std_val).unsqueeze(1)
return x_test_norm, y_test
def get_model_probabilities(
model: nn.Module,
x_data: torch.Tensor,
device: torch.device,
batch_size: int = 1000,
) -> Tuple[torch.Tensor, float]:
model.eval()
model.to(device)
probs_list = []
sync_device(device)
t0 = time.perf_counter()
with torch.no_grad():
for i in range(0, len(x_data), batch_size):
batch_x = x_data[i:i+batch_size].to(device)
logits = model(batch_x)
probs = F.softmax(logits, dim=1)
probs_list.append(probs.cpu())
sync_device(device)
wall_t = time.perf_counter() - t0
res_t = torch.cat(probs_list, dim=0)
return res_t, wall_t
# =====================================================================
# 7. Development Gate Evaluator
# =====================================================================
def evaluate_development_gates(workloads_data: Dict[str, Any]) -> Dict[str, Any]:
gate_results = {}
issues = []
# 1. All values finite & valid simplex weights
finite_ok = True
simplex_ok = True
for wl_id, wl in workloads_data.items():
methods = wl["validation"]["methods"]
for m_name, m_val in methods.items():
if m_name in ("pso_weights", "slsqp_weights", "uniform_temperature"):
mets = m_val["metrics"]
else:
mets = m_val
for k, v in mets.items():
if not math.isfinite(v):
finite_ok = False
issues.append(f"{wl_id} {m_name} metric {k}={v} non-finite")
slsqp_w = np.array(methods["slsqp_weights"]["weights"])
pso_w = np.array(methods["pso_weights"]["selected_weights"])
for name, w in [("slsqp", slsqp_w), ("pso", pso_w)]:
if np.any(w < -1e-6) or not math.isclose(np.sum(w), 1.0, abs_tol=1e-6):
simplex_ok = False
issues.append(f"{wl_id} {name} weights {w} invalid simplex")
gate_results["all_values_finite"] = finite_ok and simplex_ok
# 2. Validation pool forward passes = 5
fwd_ok = all(
wl["validation_cache"]["pool_forward_passes"] == 5
for wl in workloads_data.values()
)
gate_results["validation_pool_forward_passes_exact"] = fwd_ok
# 3. Optimization base model forward passes = 0
base_fwd_ok = all(
wl["validation_cache"]["base_cnn_forward_passes_during_optimization"] == 0
for wl in workloads_data.values()
)
gate_results["optimization_base_model_forward_passes"] = base_fwd_ok
# 4. Official test data loaded before freeze = False and evals = 0
test_leak_ok = all(
not wl.get("official_test_data_loaded_before_freeze", False) and
wl.get("official_test_evaluations_before_freeze", 0) == 0
for wl in workloads_data.values()
)
gate_results["official_test_data_loaded_before_freeze"] = test_leak_ok
# 5. SLSQP solver success
slsqp_success_ok = all(
wl["validation"]["methods"]["slsqp_weights"]["success"]
for wl in workloads_data.values()
)
if not slsqp_success_ok:
issues.append("SLSQP solver failed on one or more workloads")
gate_results["slsqp_solver_success"] = slsqp_success_ok
# 6. Exact query and sample accounting (900 queries, 9,000,000 samples per seed for Iteration 1)
acct_ok = True
for wl_id, wl in workloads_data.items():
pso_rec = wl["validation"]["methods"]["pso_weights"]
for r in pso_rec["per_seed_runs"]:
if r["queries"] != 900 or r["sample_evaluations"] != 9000000:
acct_ok = False
issues.append(f"{wl_id} seed {r['seed']} queries={r['queries']} samples={r['sample_evaluations']}")
gate_results["query_and_sample_accounting_exact"] = acct_ok
# 7. PSO validation NLL <= uniform ensemble NLL + 1e-7
pso_nll_vs_uniform_ok = True
for wl_id, wl in workloads_data.items():
pso_nll = wl["validation"]["methods"]["pso_weights"]["metrics"]["nll"]
uni_nll = wl["validation"]["methods"]["uniform_ensemble"]["nll"]
if pso_nll > uni_nll + 1e-7:
pso_nll_vs_uniform_ok = False
issues.append(f"{wl_id} PSO val NLL {pso_nll:.6f} > uniform {uni_nll:.6f}")
gate_results["maximum_pso_nll_regression_vs_uniform"] = pso_nll_vs_uniform_ok
# 8. PSO validation accuracy regression vs uniform <= 0.10 pp
pso_acc_vs_uniform_ok = True
for wl_id, wl in workloads_data.items():
pso_acc = wl["validation"]["methods"]["pso_weights"]["metrics"]["accuracy"]
uni_acc = wl["validation"]["methods"]["uniform_ensemble"]["accuracy"]
if uni_acc - pso_acc > 0.10:
pso_acc_vs_uniform_ok = False
issues.append(f"{wl_id} PSO val acc {pso_acc:.4f}% regressed >0.10pp vs uniform {uni_acc:.4f}%")
gate_results["maximum_pso_accuracy_regression_vs_uniform_pp"] = pso_acc_vs_uniform_ok
# 9. PSO validation NLL < reference single 10e NLL
pso_nll_vs_ref_ok = True
for wl_id, wl in workloads_data.items():
pso_nll = wl["validation"]["methods"]["pso_weights"]["metrics"]["nll"]
ref_nll = wl["validation"]["methods"]["reference_single_10e"]["nll"]
if pso_nll >= ref_nll:
pso_nll_vs_ref_ok = False
issues.append(f"{wl_id} PSO val NLL {pso_nll:.6f} >= ref single {ref_nll:.6f}")
gate_results["pso_nll_below_reference_single"] = pso_nll_vs_ref_ok
# 10. PSO validation NLL <= single_50e NLL + 1e-7
pso_nll_vs_50e_ok = True
for wl_id, wl in workloads_data.items():
pso_nll = wl["validation"]["methods"]["pso_weights"]["metrics"]["nll"]
s50_nll = wl["validation"]["methods"]["single_50e"]["nll"]
if pso_nll > s50_nll + 1e-7:
pso_nll_vs_50e_ok = False
issues.append(f"{wl_id} PSO val NLL {pso_nll:.6f} > single_50e {s50_nll:.6f}")
gate_results["maximum_pso_nll_regression_vs_equal_budget_single"] = pso_nll_vs_50e_ok
# 11. PSO validation NLL within 0.5% of SLSQP validation NLL
pso_gap_slsqp_ok = True
for wl_id, wl in workloads_data.items():
pso_nll = wl["validation"]["methods"]["pso_weights"]["metrics"]["nll"]
slsqp_nll = wl["validation"]["methods"]["slsqp_weights"]["metrics"]["nll"]
rel_gap = (pso_nll - slsqp_nll) / slsqp_nll
if rel_gap > 0.005:
pso_gap_slsqp_ok = False
issues.append(f"{wl_id} PSO vs SLSQP relative NLL gap {rel_gap:.4f} > 0.005")
gate_results["maximum_relative_pso_nll_gap_vs_slsqp"] = pso_gap_slsqp_ok
# 12. Cross-dataset mean relative PSO NLL reduction vs uniform >= 0.0
rel_reductions = []
for wl_id, wl in workloads_data.items():
pso_nll = wl["validation"]["methods"]["pso_weights"]["metrics"]["nll"]
uni_nll = wl["validation"]["methods"]["uniform_ensemble"]["nll"]
rel_red = (uni_nll - pso_nll) / uni_nll
rel_reductions.append(rel_red)
mean_rel_red = float(np.mean(rel_reductions)) if rel_reductions else -1.0
mean_rel_red_ok = mean_rel_red >= 0.0
if not mean_rel_red_ok:
issues.append(f"Mean relative NLL reduction vs uniform {mean_rel_red:.6f} < 0.0")
gate_results["cross_dataset_mean_relative_pso_nll_reduction_vs_uniform_minimum"] = mean_rel_red_ok
# 13. Median 1-seed PSO wall time / pool training wall time <= 0.10
wall_ratio_ok = True
for wl_id, wl in workloads_data.items():
med_pso_wall = wl["validation"]["methods"]["pso_weights"]["median_one_seed_wall_time_seconds"]
pool_wall = wl["training"]["adam_pool_wall_time_seconds"]
ratio = med_pso_wall / pool_wall if pool_wall > 0 else 1.0
if ratio > 0.10:
wall_ratio_ok = False
issues.append(f"{wl_id} PSO median wall time ratio {ratio:.4f} > 0.10")
gate_results["maximum_median_one_seed_pso_to_pool_training_wall_ratio"] = wall_ratio_ok
all_pass = all(gate_results.values())
failed_count = sum(1 for v in gate_results.values() if not v)
return {
"pass": all_pass,
"failed_hard_gate_count": failed_count,
"gate_results": gate_results,
"issues": issues,
}
# =====================================================================
# 8. Publication Output Writers (Atomic Write via os.replace)
# =====================================================================
def atomic_write_file(target_path: Path, content_str_or_bytes: Union[str, bytes], is_binary: bool = False):
target_path = Path(target_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(target_path.parent), prefix=f".tmp_{target_path.name}_")
try:
with os.fdopen(fd, 'wb' if is_binary else 'w', encoding=None if is_binary else 'utf-8') as f:
f.write(content_str_or_bytes)
os.replace(tmp_path, target_path)
except Exception:
if os.path.exists(tmp_path):
os.remove(tmp_path)
raise
def save_csv_report(artifact: Dict[str, Any], output_path: Path):
lines = [
"Workload,Phase,Method,Accuracy,NLL,Brier,ECE,Margin,WallTimeSeconds,ParameterMultiplier,InferenceMultiplier"
]
method_multipliers = {
"reference_single_10e": (1.0, 1.0),
"best_single_10e": (1.0, 1.0),
"single_50e": (1.0, 1.0),
"uniform_ensemble": (5.0, 5.0),
"uniform_temperature": (5.0, 5.0),
"slsqp_weights": (5.0, 5.0),
"pso_weights": (5.0, 5.0),
}
for wl_id, wl in artifact["workloads"].items():
# Validation Phase
val_methods = wl["validation"]["methods"]
for m_name, m_data in val_methods.items():
if m_name == "pso_weights":
mets = m_data["metrics"]
wall_t = m_data["median_one_seed_wall_time_seconds"]
elif m_name == "slsqp_weights":
mets = m_data["metrics"]
wall_t = m_data["wall_time_seconds"]
elif m_name == "uniform_temperature":
mets = m_data["metrics"]
wall_t = m_data.get("wall_time_seconds", 0.0)
else:
mets = m_data
wall_t = 0.0
param_m, inf_m = method_multipliers.get(m_name, (1.0, 1.0))
line = f"{wl_id},validation,{m_name},{mets['accuracy']:.4f},{mets['nll']:.6f},{mets['brier']:.6f},{mets['ece']:.6f},{mets['margin']:.6f},{wall_t:.4f},{param_m:.1f},{inf_m:.1f}"
lines.append(line)
# Confirmation / Test Phase
if wl.get("confirmation") is not None:
test_methods = wl["confirmation"]["methods"]
for m_name, mets in test_methods.items():
param_m, inf_m = method_multipliers.get(m_name, (1.0, 1.0))
line = f"{wl_id},official_test,{m_name},{mets['accuracy']:.4f},{mets['nll']:.6f},{mets['brier']:.6f},{mets['ece']:.6f},{mets['margin']:.6f},0.0000,{param_m:.1f},{inf_m:.1f}"
lines.append(line)
csv_content = "\n".join(lines) + "\n"
atomic_write_file(output_path, csv_content, is_binary=False)
def save_publication_plot(artifact: Dict[str, Any], output_path: Path):
"""Write the validation/test summary figure without assuming confirmation ran.
The development artifact is the primary source for this figure. Official-test
panels are intentionally left empty when development gates fail, rather than
silently reusing validation values or omitting methods from the comparison.
"""
methods_order = [
"reference_single_10e",
"best_single_10e",
"single_50e",
"uniform_ensemble",
"uniform_temperature",
"slsqp_weights",
"pso_weights",
]
method_labels = [
"Ref 10e",
"Best 10e",
"Single 50e",
"Uniform",
"Temp uniform",
"SLSQP",
"PSO",
]
workloads_data = artifact.get("workloads", {})
workloads = list(workloads_data.keys())
def _method_record(wl: Dict[str, Any], phase: str, method: str) -> Dict[str, Any]:
phase_record = wl.get(phase)
if not isinstance(phase_record, dict):
return {}
methods = phase_record.get("methods")
if not isinstance(methods, dict):
return {}
record = methods.get(method)
return record if isinstance(record, dict) else {}
def _metrics(wl: Dict[str, Any], phase: str, method: str) -> Dict[str, Any]:
record = _method_record(wl, phase, method)
nested = record.get("metrics")
return nested if isinstance(nested, dict) else record
def _number(value: Any) -> float:
try:
value = float(value)
except (TypeError, ValueError):
return float("nan")
return value if math.isfinite(value) else float("nan")
def _count_text(value: Any) -> str:
"""Format optional sample counts without assuming a complete artifact."""
try:
return f"{int(value):,}"
except (TypeError, ValueError):
return "—"
def _metric(wl: Dict[str, Any], phase: str, method: str, name: str) -> float:
return _number(_metrics(wl, phase, method).get(name))
def _grouped_metric(
ax: Any,
phase: str,
metric_name: str,
title: str,
ylabel: str,
unavailable_text: Optional[str] = None,
) -> bool:
"""Draw one phase/metric panel and return whether any value was present."""
x = np.arange(len(methods_order), dtype=float)
n_workloads = max(len(workloads), 1)
width = min(0.8 / n_workloads, 0.28)
plotted = False
observed: List[float] = []
cmap = plt.get_cmap("tab10")
for workload_idx, wl_id in enumerate(workloads):
wl = workloads_data[wl_id]
values = [
_metric(wl, phase, method, metric_name)
for method in methods_order
]
observed.extend(value for value in values if math.isfinite(value))
if any(math.isfinite(value) for value in values):
plotted = True
offset = (workload_idx - (n_workloads - 1) / 2.0) * width
ax.bar(
x + offset,
values,
width=width,
label=wl_id.replace("_", " ").title(),
color=cmap(workload_idx % 10),
alpha=0.88,
edgecolor="white",
linewidth=0.4,
)
display_title = title
if plotted and metric_name == "nll":
positive_values = [value for value in observed if value > 0]
if len(positive_values) == len(observed):
ax.set_yscale("log")
display_title = f"{title} (log scale)"
elif plotted and metric_name == "accuracy":
# Accuracy differences are sub-percentage-point on the official
# test set; a zero-based axis makes the methods indistinguishable.
low = max(0.0, min(observed) - 1.0)
high = min(100.0, max(observed) + 0.5)
if high <= low:
high = min(100.0, low + 1.0)
ax.set_ylim(low, high)
ax.set_title(display_title)
ax.set_ylabel(ylabel)
ax.set_xticks(x)
ax.set_xticklabels(method_labels, rotation=32, ha="right", fontsize=8)
ax.grid(True, axis="y", linestyle="--", alpha=0.35)
ax.set_axisbelow(True)
if not plotted and unavailable_text:
ax.text(
0.5,
0.52,
unavailable_text,
transform=ax.transAxes,
ha="center",
va="center",
fontsize=10,
color="#555555",
wrap=True,
)
return plotted
fig, axes = plt.subplots(2, 3, figsize=(18, 10), squeeze=False)
fig.suptitle(
"Post-Training Prediction-Space Ensemble Study",
fontsize=16,
fontweight="bold",
)
# The first four panels keep validation context beside the sealed,
# one-shot official-test confirmation results.
_grouped_metric(
axes[0, 0],
"validation",
"nll",
"Validation NLL (lower is better)",
"NLL",
)
has_test_nll = _grouped_metric(
axes[0, 1],
"confirmation",
"nll",
"Official-test NLL (lower is better)",
"NLL",
"Official test not run:\ndevelopment gates failed",
)
_grouped_metric(
axes[1, 0],
"validation",
"accuracy",
"Validation accuracy (higher is better)",
"Accuracy (%)",
)
has_test_accuracy = _grouped_metric(
axes[1, 1],
"confirmation",
"accuracy",
"Official-test accuracy (higher is better)",
"Accuracy (%)",
"Official test not run:\ndevelopment gates failed",
)
# Efficiency is shown separately from accuracy/NLL so the plot does not
# imply that a slower optimizer is a better ensemble method.
ax_eff = axes[0, 2]
efficiency_categories = [
"Pool\n5x10e",
"Single\n50e",
"Temp\nuniform",
"SLSQP",
"PSO\n1 seed",
"PSO\n3 seeds",
]
n_workloads = max(len(workloads), 1)
x_eff = np.arange(len(efficiency_categories), dtype=float)
width_eff = min(0.8 / n_workloads, 0.28)
efficiency_plotted = False
cmap = plt.get_cmap("tab10")
for workload_idx, wl_id in enumerate(workloads):
wl = workloads_data[wl_id]
training = wl.get("training", {})
val_methods = wl.get("validation", {}).get("methods", {})
temp_rec = val_methods.get("uniform_temperature", {})
slsqp_rec = val_methods.get("slsqp_weights", {})
pso_rec = val_methods.get("pso_weights", {})
values = [
_number(training.get("adam_pool_wall_time_seconds")),
_number(training.get("single_50e_wall_time_seconds")),
_number(temp_rec.get("wall_time_seconds")),
_number(slsqp_rec.get("wall_time_seconds")),
_number(pso_rec.get("median_one_seed_wall_time_seconds")),
_number(pso_rec.get("total_wall_time_seconds")),
]
if any(math.isfinite(value) and value > 0 for value in values):
efficiency_plotted = True
offset = (workload_idx - (n_workloads - 1) / 2.0) * width_eff
ax_eff.bar(
x_eff + offset,
values,
width=width_eff,
label=wl_id.replace("_", " ").title(),
color=cmap(workload_idx % 10),
alpha=0.88,
edgecolor="white",
linewidth=0.4,
)
ax_eff.set_title(
"Efficiency: wall time (log scale)" if efficiency_plotted
else "Efficiency: wall time"
)
ax_eff.set_ylabel("Seconds")
ax_eff.set_xticks(x_eff)
ax_eff.set_xticklabels(efficiency_categories, fontsize=8)
if efficiency_plotted:
ax_eff.set_yscale("log")
ax_eff.grid(True, axis="y", linestyle="--", alpha=0.35)
ax_eff.set_axisbelow(True)
if not efficiency_plotted:
ax_eff.text(
0.5,
0.52,
"Efficiency data unavailable",
transform=ax_eff.transAxes,
ha="center",
va="center",
fontsize=10,
color="#555555",
)
# Context panel makes the data freeze and a development-only artifact
# explicit in the publication figure.
ax_context = axes[1, 2]
ax_context.axis("off")
config = artifact.get("config", {})
resource_totals = artifact.get("resource_totals", {})
dev_pass = artifact.get("development_pass")
test_loaded = artifact.get("official_test_data_loaded")
confirmation_available = has_test_nll or has_test_accuracy
status = "PASS" if dev_pass is True else "FAIL / not confirmed"
test_status = "available" if confirmation_available else "not run"
pool_seeds = config.get("pool_seeds", [])
lines = [
"Study context",
f"Workloads: {', '.join(w.replace('_', ' ').title() for w in workloads) or 'none'}",
f"Validation: {_count_text(config.get('search_samples'))} search / "
f"{_count_text(config.get('validation_samples'))} holdout",
f"Pool: {len(pool_seeds) or 5} independently trained models",
f"Development gates: {status}",
f"Official test data: {'loaded' if test_loaded else 'sealed'}",
f"Official confirmation: {test_status}",
"",
"Prediction-space weights; no model soup",
]
pso_research = resource_totals.get(
"pso_research_wall_time_seconds",
resource_totals.get("total_pso_wall_time_seconds"),
)
pso_ratio = resource_totals.get("pso_to_pool_wall_ratio")
slsqp_total = resource_totals.get("slsqp_total_wall_time_seconds")
if pso_research is not None or slsqp_total is not None:
lines.extend(
[
"",
f"PSO research time: {_number(pso_research):.3f}s",
f"SLSQP total time: {_number(slsqp_total):.3f}s",
]
)
if pso_ratio is not None:
lines.append(f"Median workload PSO / pool ratio: {_number(pso_ratio):.2%}")
ax_context.text(
0.03,
0.97,
"\n".join(lines),
transform=ax_context.transAxes,
va="top",
ha="left",
fontsize=10,
linespacing=1.45,
family="DejaVu Sans",
)
if not confirmation_available:
ax_context.text(
0.03,
0.08,
"Validation results are retained; official-test panels are\n"
"intentionally unavailable because the policy was not confirmed.",
transform=ax_context.transAxes,
va="bottom",
ha="left",
fontsize=9,
color="#8a3b12",
wrap=True,
)
# One shared workload legend keeps the data panels uncluttered while
# preserving the dataset color mapping across all comparisons.
if workloads:
legend_handles, legend_labels = axes[0, 0].get_legend_handles_labels()
if legend_handles:
fig.legend(
legend_handles,
legend_labels,
loc="upper center",
bbox_to_anchor=(0.5, 0.925),
ncol=min(4, len(legend_labels)),
frameon=False,
fontsize=9,
title="Dataset",
)
fig.tight_layout(rect=(0, 0, 1, 0.88))
buf = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
buf_path = Path(buf.name)
buf.close()
try:
fig.savefig(buf_path, format="png", dpi=180, bbox_inches="tight")
with open(buf_path, "rb") as f:
img_bytes = f.read()
# Keep publication output atomic even when plotting or serialization
# fails partway through.
atomic_write_file(output_path, img_bytes, is_binary=True)
finally:
plt.close(fig)
if buf_path.exists():
buf_path.unlink()
# =====================================================================
# 9. Main Orchestration Function
# =====================================================================
def run_post_training_study(
cache_dir: Optional[Union[str, Path]] = None,
device: Optional[str] = None,
output_json: Optional[Union[str, Path]] = None,
output_csv: Optional[Union[str, Path]] = None,
output_png: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
if cache_dir is None:
cache_dir = Path("result/cache")
else:
cache_dir = Path(cache_dir)
if output_json is None:
output_json = Path("benchmark_results/pso_v8_post_training_ensemble.json")
else:
output_json = Path(output_json)
if output_csv is None:
output_csv = Path("benchmark_results/pso_v8_post_training_ensemble.csv")
else:
output_csv = Path(output_csv)
if output_png is None:
output_png = Path("history_plt/pso_v8_post_training_ensemble.png")
else:
output_png = Path(output_png)
dev = resolve_device(device)
print(f"[{PROTOCOL_VERSION}] Starting study on device={dev}...")
pool_seeds = [201, 202, 203, 204, 205]
swarm_seeds = [301, 302, 303]
dataset_names = ["mnist", "fashion_mnist"]
workloads: Dict[str, Any] = {}
retained_trained_models: Dict[str, Dict[str, Any]] = {}
for ds_name in dataset_names:
print(f"\n--- Workload: {ds_name} ---")
x_search, y_search, x_val, y_val, provenance = prepare_dataset_splits(
dataset_name=ds_name,
split_seed=20260904,
cache_dir=cache_dir,
)
# -------------------------------------------------------------
# Step A: Train Pool & 50e Single Model
# -------------------------------------------------------------
pool_models: Dict[int, nn.Module] = {}
pool_train_times: List[float] = []
model_fingerprints: Dict[str, str] = {}
# 1) Seed 201: Train for 10 epochs (capture reference single), then continue to 50 epochs (single_50e)
torch.manual_seed(201)
model_201 = CompactCNN().to(dev)
opt_201 = torch.optim.Adam(model_201.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
g_201 = torch.Generator()
g_201.manual_seed(201)
search_ds = TensorDataset(x_search, y_search)
loader_201 = DataLoader(search_ds, batch_size=256, shuffle=True, generator=g_201)
sync_device(dev)
t_start_201 = time.perf_counter()
model_201.train()
for epoch in range(1, 11):
for bx, by in loader_201:
bx, by = bx.to(dev), by.to(dev)
opt_201.zero_grad()
out = model_201(bx)
loss = criterion(out, by)
loss.backward()
opt_201.step()
sync_device(dev)
t_10e_201 = time.perf_counter() - t_start_201
pool_train_times.append(t_10e_201)
# Save 10e model snapshot for pool seed 201
m_201_10e = CompactCNN().to(dev)
m_201_10e.load_state_dict(copy.deepcopy(model_201.state_dict()))
pool_models[201] = m_201_10e
model_fingerprints["201"] = compute_model_fingerprint(m_201_10e)
# Continue exact same model_201 and optimizer stream to epoch 50
for epoch in range(11, 51):
for bx, by in loader_201:
bx, by = bx.to(dev), by.to(dev)
opt_201.zero_grad()
out = model_201(bx)
loss = criterion(out, by)
loss.backward()
opt_201.step()
sync_device(dev)
t_50e_201 = time.perf_counter() - t_start_201
single_50e_model = model_201
model_fingerprints["single_50e"] = compute_model_fingerprint(single_50e_model)
# 2) Seeds 202-205: Train 10 epochs each
for seed in [202, 203, 204, 205]:
torch.manual_seed(seed)
m = CompactCNN().to(dev)
opt_m = torch.optim.Adam(m.parameters(), lr=0.001)
g_m = torch.Generator()
g_m.manual_seed(seed)
loader_m = DataLoader(search_ds, batch_size=256, shuffle=True, generator=g_m)
sync_device(dev)
t_start_m = time.perf_counter()
m.train()
for epoch in range(1, 11):
for bx, by in loader_m:
bx, by = bx.to(dev), by.to(dev)
opt_m.zero_grad()
out = m(bx)
loss = criterion(out, by)
loss.backward()
opt_m.step()
sync_device(dev)
t_m = time.perf_counter() - t_start_m
pool_train_times.append(t_m)
pool_models[seed] = m
model_fingerprints[str(seed)] = compute_model_fingerprint(m)
pool_training_wall_t = float(sum(pool_train_times))
training_rec = {
"architecture": "CompactCNN",
"parameters": 9098,
"pool_seeds": pool_seeds,
"pool_epochs_each": 10,
"adam_pool_epochs": 50,
"adam_lr": 0.001,
"adam_batch_size": 256,
"adam_pool_wall_time_seconds": pool_training_wall_t,
"single_50e_epochs": 50,
"single_50e_wall_time_seconds": float(t_50e_201),
"model_fingerprints": model_fingerprints,
}
# Retain trained models in memory for exact official test evaluation without retraining
retained_trained_models[ds_name] = {
"pool_models": pool_models,
"single_50e_model": single_50e_model,
}
# -------------------------------------------------------------
# Step B: Create Validation Probability Cache
# -------------------------------------------------------------
sync_device(dev)
t0_val_cache = time.perf_counter()
val_probs_list = []
for seed in pool_seeds:
p_m, _ = get_model_probabilities(pool_models[seed], x_val, dev)
val_probs_list.append(p_m)
val_pool_probs_t = torch.stack(val_probs_list, dim=0) # (5, 10000, 10)
cache_valid = validate_probability_cache(val_pool_probs_t)
val_50e_probs_t, _ = get_model_probabilities(single_50e_model, x_val, dev)
sync_device(dev)
val_cache_wall_t = time.perf_counter() - t0_val_cache
val_pool_bytes = int(val_pool_probs_t.element_size() * val_pool_probs_t.nelement()) + int(val_50e_probs_t.element_size() * val_50e_probs_t.nelement())
validation_cache_rec = {
"valid": cache_valid,
"pool_forward_passes": 5,
"long_single_forward_passes": 1,
"base_cnn_forward_passes_during_optimization": 0,
"shape": list(val_pool_probs_t.shape),
"memory_bytes": val_pool_bytes,
"wall_time_seconds": float(val_cache_wall_t),
}
# -------------------------------------------------------------
# Step C: Evaluate Validation Baselines & Optimization Methods
# -------------------------------------------------------------
val_pool_probs_np = val_pool_probs_t.numpy()
val_50e_probs_np = val_50e_probs_t.numpy()
y_val_np = y_val.numpy()
# 1) reference_single_10e (seed 201)
ref_single_metrics = probabilistic_metrics(val_pool_probs_np[0], y_val_np)
# 2) best_single_10e
pool_nlls = [probabilistic_metrics(val_pool_probs_np[i], y_val_np)["nll"] for i in range(5)]
best_single_idx = int(np.argmin(pool_nlls))
best_single_seed = pool_seeds[best_single_idx]
best_single_metrics = probabilistic_metrics(val_pool_probs_np[best_single_idx], y_val_np)
best_single_metrics["selected_seed"] = best_single_seed
# 3) single_50e
s50e_metrics = probabilistic_metrics(val_50e_probs_np, y_val_np)
# 4) uniform_ensemble
uniform_probs = val_pool_probs_np.mean(axis=0)
uniform_metrics = probabilistic_metrics(uniform_probs, y_val_np)
# 5) uniform_temperature
fitted_temp, uniform_temp_rec = fit_uniform_temperature(uniform_probs, y_val_np)
# 6) slsqp_weights
slsqp_rec = optimize_slsqp_weights(val_pool_probs_np, y_val_np)
# 7) pso_weights (Iteration 1: 30 particles x 30 epochs = 900 queries per seed)
pso_rec = run_pso_weights(val_pool_probs_t, y_val, swarm_seeds=swarm_seeds, particles=30, epochs=30, device=str(dev))
validation_rec = {
"methods": {
"reference_single_10e": ref_single_metrics,
"best_single_10e": best_single_metrics,
"single_50e": s50e_metrics,
"uniform_ensemble": uniform_metrics,
"uniform_temperature": uniform_temp_rec,
"slsqp_weights": slsqp_rec,
"pso_weights": pso_rec,
}
}
workloads[ds_name] = {
"provenance": provenance,
"training": training_rec,
"validation_cache": validation_cache_rec,
"validation": validation_rec,
"official_test_data_loaded_before_freeze": False,
"official_test_evaluations_before_freeze": 0,
"confirmation": None,
}
# -----------------------------------------------------------------
# Step D: Development Gates Evaluation & Policy Freeze
# -----------------------------------------------------------------
dev_gate_res = evaluate_development_gates(workloads)
dev_pass = dev_gate_res["pass"]
print(f"\n=== Development Phase Summary ===")
print(f"Development Pass: {dev_pass} (Failed Gates: {dev_gate_res['failed_hard_gate_count']})")
for g_name, g_status in dev_gate_res["gate_results"].items():
print(f" - {g_name}: {'PASS' if g_status else 'FAIL'}")
if dev_gate_res["issues"]:
print("Issues:")
for iss in dev_gate_res["issues"]:
print(f" * {iss}")
policy_frozen = True
official_test_data_loaded = False
# -----------------------------------------------------------------
# Step E: Deferred Official Test Loading & Evaluation (If Dev Passed)
# Reuses retained models directly; NEVER retrains after freeze.
# -----------------------------------------------------------------
if dev_pass:
print("\n=== Official Test Confirmation Phase ===")
official_test_data_loaded = True
for ds_name in dataset_names:
wl = workloads[ds_name]
mean_v = wl["provenance"]["normalization"]["mean"]
std_v = wl["provenance"]["normalization"]["std"]
x_test, y_test = load_official_test_data(
dataset_name=ds_name,
mean_val=mean_v,
std_val=std_v,
cache_dir=cache_dir,
)
# Reuse retained trained models directly (NO RETRAINING)
ret_pool = retained_trained_models[ds_name]["pool_models"]
ret_50e = retained_trained_models[ds_name]["single_50e_model"]
sync_device(dev)
t0_test_cache = time.perf_counter()
test_pool_probs_list = []
for seed in pool_seeds:
p_m, _ = get_model_probabilities(ret_pool[seed], x_test, dev)
test_pool_probs_list.append(p_m)
p_test_50e, _ = get_model_probabilities(ret_50e, x_test, dev)
sync_device(dev)
test_cache_wall_t = time.perf_counter() - t0_test_cache
test_pool_probs_t = torch.stack(test_pool_probs_list, dim=0) # (5, 10000, 10)
test_bytes = int(test_pool_probs_t.element_size() * test_pool_probs_t.nelement()) + int(p_test_50e.element_size() * p_test_50e.nelement())
test_pool_probs_np = test_pool_probs_t.numpy()
test_50e_probs_np = p_test_50e.numpy()
y_test_np = y_test.numpy()
# Retrieve frozen parameters from validation phase
frozen_pso_w = wl["validation"]["methods"]["pso_weights"]["selected_weights"]
frozen_pso_seed = wl["validation"]["methods"]["pso_weights"]["selected_seed"]
frozen_slsqp_w = wl["validation"]["methods"]["slsqp_weights"]["weights"]
frozen_temp = wl["validation"]["methods"]["uniform_temperature"]["fitted_temperature"]
val_best_seed_idx = pool_seeds.index(wl["validation"]["methods"]["best_single_10e"]["selected_seed"])
# Evaluate frozen methods on official test cache
test_ref_single = probabilistic_metrics(test_pool_probs_np[0], y_test_np)
test_best_single = probabilistic_metrics(test_pool_probs_np[val_best_seed_idx], y_test_np)
test_single_50e = probabilistic_metrics(test_50e_probs_np, y_test_np)
test_uniform_probs = test_pool_probs_np.mean(axis=0)
test_uniform_ensemble = probabilistic_metrics(test_uniform_probs, y_test_np)
test_temp_probs = temp_scaled_probs(test_uniform_probs, frozen_temp)
test_uniform_temp = probabilistic_metrics(test_temp_probs, y_test_np)
test_slsqp_mix = mixture_probabilities(frozen_slsqp_w, test_pool_probs_np)
test_slsqp = probabilistic_metrics(test_slsqp_mix, y_test_np)
test_pso_mix = mixture_probabilities(frozen_pso_w, test_pool_probs_np)
test_pso = probabilistic_metrics(test_pso_mix, y_test_np)
# Confirmation Gates
c_finite = all(
math.isfinite(v)
for m_dict in [test_ref_single, test_best_single, test_single_50e, test_uniform_ensemble, test_uniform_temp, test_slsqp, test_pso]
for v in m_dict.values()
)
c_acc_reg = (test_uniform_ensemble["accuracy"] - test_pso["accuracy"]) <= 0.20
c_nll_ref = test_pso["nll"] < test_ref_single["nll"]
c_nll_50e = test_pso["nll"] <= test_single_50e["nll"] + 1e-7
c_pass = c_finite and c_acc_reg and c_nll_ref and c_nll_50e
confirmation_rec = {
"official_test_data_loaded": True,
"test_cache_counts": {
"dataset_loads": 1,
"pool_forward_passes": 5,
"long_single_forward_passes": 1,
"base_cnn_forward_passes_during_optimization": 0,
"memory_bytes": test_bytes,
"wall_time_seconds": float(test_cache_wall_t),
},
"frozen_methods": {
"selected_pso_seed": frozen_pso_seed,
"selected_pso_weights": frozen_pso_w,
"slsqp_weights": frozen_slsqp_w,
"fitted_temperature": frozen_temp,
},
"methods": {
"reference_single_10e": test_ref_single,
"best_single_10e": test_best_single,
"single_50e": test_single_50e,
"uniform_ensemble": test_uniform_ensemble,
"uniform_temperature": test_uniform_temp,
"slsqp_weights": test_slsqp,
"pso_weights": test_pso,
},
"confirmation_gates": {
"all_values_finite": c_finite,
"official_test_dataset_loads": 1,
"official_test_pool_forward_passes": 5,
"official_test_long_single_forward_passes": 1,
"maximum_pso_accuracy_regression_vs_uniform_pp": c_acc_reg,
"pso_nll_below_reference_single": c_nll_ref,
"maximum_pso_nll_regression_vs_equal_budget_single": c_nll_50e,
"pass": c_pass,
}
}
wl["confirmation"] = confirmation_rec
# -----------------------------------------------------------------
# Step F: Assemble Global Artifact & Resource Totals
# -----------------------------------------------------------------
adam_pool_epochs = sum(wl["training"]["adam_pool_epochs"] for wl in workloads.values())
adam_pool_wall_t = float(sum(wl["training"]["adam_pool_wall_time_seconds"] for wl in workloads.values()))
single_50e_wall_t = float(sum(wl["training"]["single_50e_wall_time_seconds"] for wl in workloads.values()))
val_fwd_passes = sum(
wl["validation_cache"]["pool_forward_passes"] + wl["validation_cache"]["long_single_forward_passes"]
for wl in workloads.values()
)
pso_tot_queries = sum(wl["validation"]["methods"]["pso_weights"]["total_queries"] for wl in workloads.values())
pso_tot_samples = sum(wl["validation"]["methods"]["pso_weights"]["total_sample_evaluations"] for wl in workloads.values())
pso_res_wall_t = float(sum(wl["validation"]["methods"]["pso_weights"]["total_wall_time_seconds"] for wl in workloads.values()))
pso_prod_wall_t = float(sum(
next(r["wall_time_seconds"] for r in wl["validation"]["methods"]["pso_weights"]["per_seed_runs"]
if r["seed"] == wl["validation"]["methods"]["pso_weights"]["selected_seed"])
for wl in workloads.values()
))
slsqp_tot_evals = sum(wl["validation"]["methods"]["slsqp_weights"]["evaluations"] for wl in workloads.values())
slsqp_tot_wall_t = float(sum(wl["validation"]["methods"]["slsqp_weights"]["wall_time_seconds"] for wl in workloads.values()))
test_fwd_passes = sum(
wl["confirmation"]["test_cache_counts"]["pool_forward_passes"] + wl["confirmation"]["test_cache_counts"]["long_single_forward_passes"]
if wl.get("confirmation") is not None else 0
for wl in workloads.values()
)
pso_to_pool_ratios = [
wl["validation"]["methods"]["pso_weights"]["median_one_seed_wall_time_seconds"] / wl["training"]["adam_pool_wall_time_seconds"]
for wl in workloads.values()
]
pso_to_pool_wall_ratio = float(np.median(pso_to_pool_ratios)) if pso_to_pool_ratios else 0.0
pso_max_workload_wall_ratio = float(max(pso_to_pool_ratios)) if pso_to_pool_ratios else 0.0
pso_production_to_pool_wall_ratio = (
pso_prod_wall_t / adam_pool_wall_t if adam_pool_wall_t > 0 else 0.0
)
resource_totals = {
"adam_pool_epochs": adam_pool_epochs,
"adam_pool_wall_time_seconds": adam_pool_wall_t,
"single_50e_wall_time_seconds": single_50e_wall_t,
"validation_cache_forward_passes": val_fwd_passes,
"pso_total_queries": pso_tot_queries,
"pso_total_sample_evaluations": pso_tot_samples,
"pso_research_wall_time_seconds": pso_res_wall_t,
"pso_production_wall_time_seconds": pso_prod_wall_t,
"pso_to_pool_wall_ratio": pso_to_pool_wall_ratio,
"pso_max_workload_wall_ratio": pso_max_workload_wall_ratio,
"pso_production_to_pool_wall_ratio": pso_production_to_pool_wall_ratio,
"slsqp_total_evaluations": slsqp_tot_evals,
"slsqp_total_wall_time_seconds": slsqp_tot_wall_t,
"official_test_cache_forward_passes": test_fwd_passes,
}
artifact = {
"protocol_version": PROTOCOL_VERSION,
"config": {
"iteration": 1,
"archived_iteration0_reference": {
"epochs": 50,
"queries_per_seed": 1500,
"sample_evaluations_per_seed": 15000000,
"reason": "wall_time_ratio_gate_exceeded",
},
"datasets": dataset_names,
"split_seed": 20260904,
"search_samples": 50000,
"validation_samples": 10000,
"pool_seeds": pool_seeds,
"reference_single_seed": 201,
"equal_budget_single_epochs": 50,
"adam_lr": 0.001,
"adam_batch_size": 256,
"pso": {
"method": "constriction",
"evaluation": "full",
"renewal": "loss",
"particles": 30,
"epochs": 30,
"swarm_seeds": swarm_seeds,
"queries_per_seed": 900,
"sample_evaluations_per_seed": 9000000,
"particle_bounds": [-4.0, 4.0],
"boundary_strategy": "reflect",
"velocity_limit_ratio": 0.1,
"initial_position_noise": 0.0,
},
"device": str(dev),
},
"workloads": workloads,
"development_pass": dev_pass,
"development_gates": dev_gate_res,
"policy_frozen": policy_frozen,
"official_test_data_loaded": official_test_data_loaded,
"official_test_evaluations_before_freeze": 0,
"post_test_tuning_or_reruns": 0,
"resource_totals": resource_totals,
}
# Write output artifacts atomically using temporary files + os.replace
json_bytes = json.dumps(artifact, indent=2).encode("utf-8")
atomic_write_file(output_json, json_bytes, is_binary=True)
print(f"\nArtifact saved to: {output_json}")
save_csv_report(artifact, output_csv)
print(f"CSV report saved to: {output_csv}")
save_publication_plot(artifact, output_png)
print(f"PNG plot saved to: {output_png}")
return artifact
def main():
parser = argparse.ArgumentParser(description="Post-Training PSO Ensemble Study Runner")
parser.add_argument("--device", type=str, default=None, help="Device to use (cpu, mps, cuda)")
parser.add_argument("--cache-dir", type=str, default="result/cache", help="Dataset cache directory")
parser.add_argument("--output-json", type=str, default="benchmark_results/pso_v8_post_training_ensemble.json", help="JSON output path")
parser.add_argument("--output-csv", type=str, default="benchmark_results/pso_v8_post_training_ensemble.csv", help="CSV output path")
parser.add_argument("--output-png", type=str, default="history_plt/pso_v8_post_training_ensemble.png", help="PNG output path")
args = parser.parse_args()
run_post_training_study(
cache_dir=args.cache_dir,
device=args.device,
output_json=args.output_json,
output_csv=args.output_csv,
output_png=args.output_png,
)
if __name__ == "__main__":
main()