Files
PSO/test/deep_pso_methods.py
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

1177 lines
46 KiB
Python

"""
MNIST Deep PSO Methods Study: Latent Space Subspaces, Adaptive-Moment PSO, and Ensembling
Protocol: MNIST-PSO-RAW-V5 1.0.0
- Official raw MNIST (60,000 train / 10,000 test).
- Split 60k train first into 50k search and 10k validation using deterministic stratified sampling (seed 20260902).
- Mean and std fit on 50k search subset ONLY; applied to search, validation, and test.
- Nested stratified ordering: 2k subset inside 10k subset inside 50k search set.
- Base CompactCNN (9,098 parameters).
- Latent subspace transform via deterministic sparse signed hash mapping for d in [290, 1024, 4096, full].
- Exact base model at particle 0; remaining particles in antithetic pairs.
- Device-resident latent adaptive-moment PSO (c0=c1=1.49618, w=0.7298, blend=0.06, step=0.5, beta1=0.9, beta2=0.999).
- Lexicographical CE loss primary, accuracy tie-break selection.
- Objective transition: complete pbest re-evaluation and gbest rebuild.
- Validation-only pilot selection and elite selection.
- Official 10k test set evaluated exactly once per final reported endpoint.
"""
import argparse
import csv
import datetime
import hashlib
import json
import math
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
from sklearn.model_selection import train_test_split
sys.path.insert(0, str(Path(__file__).resolve().parent))
from benchmark_suite import (
calc_stats,
compute_data_fingerprint,
compute_model_fingerprint,
get_hardware_provenance,
resolve_execution_device,
save_json_atomic,
sync_device,
)
from pso import __version__ as pso_version
PROTOCOL_VERSION = "MNIST-PSO-RAW-V5 1.0.0"
# =====================================================================
# 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 make_compact_cnn(seed: int = 41) -> nn.Module:
torch.manual_seed(seed)
return CompactCNN()
# =====================================================================
# 2. Data Split, Normalization, & Stratified Subsets
# =====================================================================
def prepare_mnist_v5_data(
split_seed: int = 20260902,
cache_dir: Optional[Path] = None,
) -> Tuple[
torch.Tensor, torch.Tensor,
torch.Tensor, torch.Tensor,
torch.Tensor, torch.Tensor,
Dict[int, torch.Tensor],
str, Dict[str, Any]
]:
from torchvision.datasets import MNIST
if cache_dir is None:
cache_dir = Path("result/cache")
cache_dir.mkdir(parents=True, exist_ok=True)
raw_train = MNIST(root=str(cache_dir), train=True, download=True)
raw_test = MNIST(root=str(cache_dir), train=False, download=True)
x_train_raw = raw_train.data.float() / 255.0 # (60000, 28, 28)
y_train_raw = raw_train.targets.long()
x_test_raw = raw_test.data.float() / 255.0 # (10000, 28, 28)
y_test_raw = raw_test.targets.long()
# Stratified split: 50,000 search set and 10,000 validation set
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]
# Fit mean and std on 50k search subset ONLY
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) # (50000, 1, 28, 28)
x_val_norm = ((x_val_raw - mean_val) / std_val).unsqueeze(1) # (10000, 1, 28, 28)
x_test_norm = ((x_test_raw - mean_val) / std_val).unsqueeze(1) # (10000, 1, 28, 28)
# Nested stratified subsets inside 50k search set: 2k inside 10k inside 50k
nested_subsets = build_nested_stratified_subsets(
y_search=y_search,
subset_sizes=[2000, 10000, 50000],
subset_seed=split_seed,
)
# Data fingerprint over search and test
data_fp = compute_data_fingerprint(x_search_norm, x_test_norm, y_search, y_test_raw)
split_h = hashlib.sha256()
split_h.update(search_idx.tobytes())
split_h.update(val_idx.tobytes())
split_fp = split_h.hexdigest()[:16]
provenance = {
"input_shape": [1, 28, 28],
"pca": False,
"raw_inputs": True,
"normalization_scope": "search_train_50000_only",
"train_mean": round(mean_val, 6),
"train_std": round(std_val, 6),
"search_samples": 50000,
"val_samples": 10000,
"test_samples": 10000,
"split_seed": split_seed,
"split_fingerprint": split_fp,
}
return (
x_search_norm, y_search,
x_val_norm, y_val,
x_test_norm, y_test_raw,
nested_subsets,
data_fp, provenance
)
def build_nested_stratified_subsets(
y_search: torch.Tensor,
subset_sizes: List[int],
subset_seed: int = 20260902,
) -> Dict[int, torch.Tensor]:
"""
Builds nested stratified index tensors: I_2k subset of I_10k subset of I_50k.
Uses Hamilton / Largest-Remainder Method for exact subset sizes and nesting.
"""
rng = np.random.RandomState(subset_seed)
y_np = y_search.numpy()
total_samples = len(y_np)
unique_classes, counts = np.unique(y_np, return_counts=True)
class_indices = {}
for c in unique_classes:
c_idxs = np.where(y_np == c)[0]
rng.shuffle(c_idxs)
class_indices[c] = c_idxs
ordered_subset_sizes = sorted(subset_sizes)
nested_subsets: Dict[int, torch.Tensor] = {}
selected_per_class: Dict[int, List[int]] = {c: [] for c in unique_classes}
for size in ordered_subset_sizes:
if size == total_samples:
nested_subsets[size] = torch.arange(total_samples, dtype=torch.long)
continue
exact_quotas = [size * (counts[i] / total_samples) for i in range(len(unique_classes))]
floor_quotas = [int(np.floor(q)) for q in exact_quotas]
remainders = [exact_quotas[i] - floor_quotas[i] for i in range(len(unique_classes))]
needed_extra = size - sum(floor_quotas)
ranked_indices = np.argsort(remainders)[::-1]
target_counts = list(floor_quotas)
for i in range(needed_extra):
target_counts[ranked_indices[i]] += 1
target_subset_idxs = []
for i, c in enumerate(unique_classes):
target_c_count = target_counts[i]
current_list = selected_per_class[c]
needed = target_c_count - len(current_list)
if needed > 0:
available = class_indices[c]
added = list(available[len(current_list):len(current_list) + needed])
current_list.extend(added)
target_subset_idxs.extend(current_list[:target_c_count])
subset_tensor = torch.tensor(sorted(target_subset_idxs), dtype=torch.long)
nested_subsets[size] = subset_tensor
return nested_subsets
# =====================================================================
# 3. Latent Subspace Transform & Antithetic Swarm Construction
# =====================================================================
class LatentTransform:
def __init__(
self,
base_model: nn.Module,
latent_dim: Union[int, str],
device: torch.device,
):
self.device = device
self.base_params = [p.detach().clone().to(device) for p in base_model.parameters()]
self.param_shapes = [p.shape for p in self.base_params]
self.param_numels = [p.numel() for p in self.base_params]
self.total_dim = sum(self.param_numels)
# Per-tensor scale calculation: positive scale per parameter tensor
tensor_scales = []
for p in self.base_params:
std_val = float(p.std())
scale = max(std_val, 1e-4)
scale_tensor = torch.full_like(p, scale)
tensor_scales.append(scale_tensor.view(-1))
self.scale_vec = torch.cat(tensor_scales).to(device)
self.base_vec = torch.cat([p.view(-1) for p in self.base_params]).to(device)
if isinstance(latent_dim, str) and latent_dim.lower() == "full":
self.latent_dim = self.total_dim
self.is_full = True
else:
self.latent_dim = int(latent_dim)
self.is_full = (self.latent_dim == self.total_dim)
if not self.is_full:
# Deterministic sparse signed hash mapping
j_indices = np.arange(self.total_dim, dtype=np.int64)
h1 = ((j_indices + 1) * 2654435761) % (2**32)
k_indices = h1 % self.latent_dim
h2 = ((j_indices + 1) * 1597334677) % (2**32)
signs = np.where((h2 % 2) == 0, 1.0, -1.0)
# Count normalization to maintain unit variance
bin_counts = np.bincount(k_indices, minlength=self.latent_dim)
count_per_j = bin_counts[k_indices]
scale_per_j = 1.0 / np.sqrt(np.maximum(count_per_j, 1))
combined_weights = signs * scale_per_j
self.k_indices = torch.tensor(k_indices, dtype=torch.long, device=device)
self.weights = torch.tensor(combined_weights, dtype=torch.float32, device=device)
def decode(self, Z: torch.Tensor) -> torch.Tensor:
"""
Transforms latent batch Z (N, d) into full parameter batch (N, D).
theta = base_vec + scale_vec * delta
"""
if self.is_full:
delta = Z
else:
delta = Z[:, self.k_indices] * self.weights
return self.base_vec + self.scale_vec * delta
def load_vector_to_model(self, theta_vec: torch.Tensor, model: nn.Module):
"""Loads a single parameter vector into model parameters in-place."""
offset = 0
with torch.no_grad():
for p, shape, numel in zip(model.parameters(), self.param_shapes, self.param_numels):
p.copy_(theta_vec[offset:offset + numel].view(shape))
offset += numel
def init_swarm(self, swarm_size: int, seed: int, init_radius: float = 0.5) -> torch.Tensor:
"""
Initializes particle positions in latent space Z (N, d).
Particle 0 is exact base vector (z = 0).
For even swarm sizes N, particles 1..N-2 form (N-2)//2 exact pairs, and particle N-1 is zero.
"""
rng = torch.Generator(device="cpu")
rng.manual_seed(seed)
Z = torch.zeros((swarm_size, self.latent_dim), dtype=torch.float32)
# Particle 0 stays exact 0
max_pair_idx = swarm_size - 1 if (swarm_size % 2 != 0) else swarm_size - 2
idx = 1
while idx < max_pair_idx:
sample = (torch.rand(self.latent_dim, generator=rng) * 2.0 - 1.0) * init_radius
Z[idx] = sample
Z[idx + 1] = -sample
idx += 2
return Z.to(self.device)
# =====================================================================
# 4. Device-Resident Latent Adaptive-Moment PSO Engine
# =====================================================================
def evaluate_latent_batch(
Z: torch.Tensor,
transform: LatentTransform,
model: nn.Module,
x_sub_dev: torch.Tensor,
y_sub_dev: torch.Tensor,
batch_size: int = 1000,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Evaluates latent batch Z (N, d) on device-resident (x_sub_dev, y_sub_dev) without host roundtrips.
Returns (losses, accuracies) tensors of shape (N,).
"""
N = Z.shape[0]
device = Z.device
losses = torch.zeros(N, dtype=torch.float32, device=device)
accuracies = torch.zeros(N, dtype=torch.float32, device=device)
loss_fn = nn.CrossEntropyLoss(reduction="sum")
num_samples = len(y_sub_dev)
model.eval()
with torch.inference_mode():
for i in range(N):
theta_vec = transform.decode(Z[i:i+1]).squeeze(0)
transform.load_vector_to_model(theta_vec, model)
total_loss = torch.tensor(0.0, device=device)
correct = torch.tensor(0, dtype=torch.long, device=device)
for b_start in range(0, num_samples, batch_size):
xb = x_sub_dev[b_start:b_start + batch_size]
yb = y_sub_dev[b_start:b_start + batch_size]
logits = model(xb)
batch_loss = loss_fn(logits, yb)
total_loss += batch_loss
preds = logits.argmax(dim=1)
correct += (preds == yb).sum()
losses[i] = total_loss / num_samples
accuracies[i] = (correct.float() / num_samples) * 100.0
return losses, accuracies
def run_latent_pso(
transform: LatentTransform,
base_model: nn.Module,
x_search: torch.Tensor,
y_search: torch.Tensor,
nested_subsets: Dict[int, torch.Tensor],
schedule_str: str,
epochs: int,
swarm_size: int,
seed: int,
device: torch.device,
) -> Dict[str, Any]:
"""
Runs device-resident Latent Adaptive-Moment PSO with progressive schedule.
Performs full pbest re-evaluation and gbest rebuild ONLY on objective transitions (stages > 0).
"""
sync_device(device)
start_time = time.time()
schedule_stages = []
if schedule_str:
parts = schedule_str.split(",")
for p in parts:
sz_str, ep_str = p.split(":")
schedule_stages.append((int(sz_str), int(ep_str)))
if not schedule_stages:
schedule_stages = [(50000, epochs)]
c0 = c1 = 1.49618
w = 0.7298
blend = 0.06
step = 0.5
beta1 = 0.9
beta2 = 0.999
reflective_bound = 3.0
latent_dim = transform.latent_dim
Z = transform.init_swarm(swarm_size=swarm_size, seed=seed)
V = torch.zeros((swarm_size, latent_dim), dtype=torch.float32, device=device)
M = torch.zeros((swarm_size, latent_dim), dtype=torch.float32, device=device)
V_sq = torch.zeros((swarm_size, latent_dim), dtype=torch.float32, device=device)
# State tracking: P scores start at inf / 0. DO NOT evaluate before stage 0!
P = Z.clone()
P_loss = torch.full((swarm_size,), float("inf"), dtype=torch.float32, device=device)
P_acc = torch.zeros((swarm_size,), dtype=torch.float32, device=device)
gbest_z = Z[0].clone()
gbest_loss = float("inf")
gbest_acc = 0.0
total_queries = 0
total_sample_evaluations = 0
transition_reevaluation_counts = 0
rng = torch.Generator(device=device)
rng.manual_seed(seed)
model = make_compact_cnn(seed=41).to(device)
stage_histories = []
t_step = 0
epoch_counter = 0
for stage_idx, (size, stage_epochs) in enumerate(schedule_stages):
subset_indices = nested_subsets[size]
# Move stage subset to device ONCE per stage
x_sub_dev = x_search[subset_indices].to(device)
y_sub_dev = y_search[subset_indices].to(device)
# Objective transition check: ONLY for stages >= 1!
if stage_idx > 0:
re_losses, re_accs = evaluate_latent_batch(
P, transform, model, x_sub_dev, y_sub_dev
)
P_loss = re_losses
P_acc = re_accs
total_queries += swarm_size
total_sample_evaluations += swarm_size * size
transition_reevaluation_counts += swarm_size
min_loss_val = P_loss.min()
candidates_mask = (P_loss <= min_loss_val + 1e-7)
best_p_idx = int(torch.where(candidates_mask, P_acc, torch.tensor(-1.0, device=device)).argmax().item())
gbest_z = P[best_p_idx].clone()
gbest_loss = float(P_loss[best_p_idx].item())
gbest_acc = float(P_acc[best_p_idx].item())
for ep in range(1, stage_epochs + 1):
epoch_counter += 1
# 1. Evaluate current swarm positions
curr_losses, curr_accs = evaluate_latent_batch(
Z, transform, model, x_sub_dev, y_sub_dev
)
total_queries += swarm_size
total_sample_evaluations += swarm_size * size
# 2. Vectorized on-device pbest updates (lexicographical loss primary, acc tie-break)
better_loss = curr_losses < P_loss - 1e-7
equal_loss = torch.abs(curr_losses - P_loss) <= 1e-7
better_acc = curr_accs > P_acc
update_mask = better_loss | (equal_loss & better_acc)
P[update_mask] = Z[update_mask]
P_loss[update_mask] = curr_losses[update_mask]
P_acc[update_mask] = curr_accs[update_mask]
# Rebuild gbest from P each epoch on-device
min_loss_val = P_loss.min()
candidates_mask = (P_loss <= min_loss_val + 1e-7)
best_p_idx = int(torch.where(candidates_mask, P_acc, torch.tensor(-1.0, device=device)).argmax().item())
gbest_z = P[best_p_idx].clone()
gbest_loss = float(P_loss[best_p_idx].item())
gbest_acc = float(P_acc[best_p_idx].item())
stage_histories.append({
"epoch": epoch_counter,
"stage": stage_idx,
"subset_size": size,
"gbest_loss": round(gbest_loss, 6),
"gbest_acc": round(gbest_acc, 4),
})
# 3. Adaptive Moment Movement Step
r1 = torch.rand((swarm_size, latent_dim), generator=rng, device=device)
r2 = torch.rand((swarm_size, latent_dim), generator=rng, device=device)
V_raw = w * V + c0 * r1 * (P - Z) + c1 * r2 * (gbest_z.unsqueeze(0) - Z)
t_step += 1
M = beta1 * M + (1 - beta1) * V_raw
V_sq = beta2 * V_sq + (1 - beta2) * (V_raw ** 2)
M_hat = M / (1.0 - beta1 ** t_step)
V_sq_hat = V_sq / (1.0 - beta2 ** t_step)
dir_moment = M_hat / (torch.sqrt(V_sq_hat) + 1e-8)
historical_scale = torch.sqrt(torch.mean(V_sq_hat, dim=1, keepdim=True))
V_moment = step * dir_moment * historical_scale
V_new = (1.0 - blend) * V_raw + blend * V_moment
Z_new = Z + V_new
pos_mask = Z_new > reflective_bound
neg_mask = Z_new < -reflective_bound
Z_new[pos_mask] = 2.0 * reflective_bound - Z_new[pos_mask]
V_new[pos_mask] = -V_new[pos_mask]
Z_new[neg_mask] = -2.0 * reflective_bound - Z_new[neg_mask]
V_new[neg_mask] = -V_new[neg_mask]
Z_new = torch.clamp(Z_new, -reflective_bound, reflective_bound)
Z = Z_new
V = V_new
if epoch_counter >= epochs:
break
if epoch_counter >= epochs:
break
sync_device(device)
wall_time = time.time() - start_time
return {
"gbest_z": gbest_z,
"gbest_loss": gbest_loss,
"gbest_acc": gbest_acc,
"final_P": P,
"wall_time_sec": round(wall_time, 4),
"total_queries": total_queries,
"total_sample_evaluations": total_sample_evaluations,
"transition_reevaluation_counts": transition_reevaluation_counts,
"stage_histories": stage_histories,
}
# =====================================================================
# 5. Evaluation Metrics (NLL, Brier, ECE, Margin, Disagreement)
# =====================================================================
def evaluate_probabilistic_metrics(
prob_matrix: torch.Tensor,
y_true: torch.Tensor,
) -> Dict[str, float]:
"""
Computes accuracy, NLL, Brier score, 15-bin ECE, and probability margin.
"""
N, C = prob_matrix.shape
probs = prob_matrix.cpu().numpy()
labels = y_true.cpu().numpy()
preds = probs.argmax(axis=1)
acc = float((preds == labels).mean()) * 100.0
eps = 1e-12
clipped_probs = np.clip(probs, eps, 1.0 - eps)
nll = -float(np.log(clipped_probs[np.arange(N), labels]).mean())
y_onehot = np.zeros((N, C), dtype=np.float32)
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 = in_bin.mean()
if prop_in_bin > 0:
accuracy_in_bin = (preds[in_bin] == labels[in_bin]).mean()
avg_confidence_in_bin = confidences[in_bin].mean()
ece += np.abs(accuracy_in_bin - avg_confidence_in_bin) * prop_in_bin
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),
}
def compute_pairwise_disagreement(model_preds_list: List[np.ndarray]) -> float:
num_models = len(model_preds_list)
if num_models < 2:
return 0.0
disagreements = []
for i in range(num_models):
for j in range(i + 1, num_models):
dis = float((model_preds_list[i] != model_preds_list[j]).mean())
disagreements.append(dis)
return round(float(np.mean(disagreements)), 6)
def select_diverse_candidates(
candidates: List[Dict[str, Any]],
*,
max_size: int = 5,
accuracy_window: float = 2.0,
) -> List[Dict[str, Any]]:
if not candidates:
raise ValueError("candidates must not be empty")
if max_size <= 0:
raise ValueError("max_size must be positive")
if accuracy_window < 0.0:
raise ValueError("accuracy_window must be nonnegative")
ranked = sorted(candidates, key=lambda c: (c["val_loss"], -c["val_acc"]))
accuracy_threshold = ranked[0]["val_acc"] - accuracy_window
eligible = [c for c in ranked if c["val_acc"] >= accuracy_threshold]
selected = [eligible[0]]
selected_keys = {(eligible[0]["seed"], eligible[0]["particle_idx"])}
predictions = {
(candidate["seed"], candidate["particle_idx"]):
candidate["val_probs"].argmax(dim=1).cpu().numpy()
for candidate in eligible
}
while len(selected) < min(max_size, len(eligible)):
best_next = None
best_key = None
max_disagreement = -1.0
best_val_loss = float("inf")
for candidate in eligible:
candidate_key = (candidate["seed"], candidate["particle_idx"])
if candidate_key in selected_keys:
continue
candidate_predictions = predictions[candidate_key]
mean_disagreement = float(np.mean([
(candidate_predictions - predictions[
(member["seed"], member["particle_idx"])
] != 0).mean()
for member in selected
]))
if (
mean_disagreement > max_disagreement + 1e-7
or (
abs(mean_disagreement - max_disagreement) <= 1e-7
and candidate["val_loss"] < best_val_loss
)
):
best_next = candidate
best_key = candidate_key
max_disagreement = mean_disagreement
best_val_loss = candidate["val_loss"]
if best_next is None or best_key is None:
break
selected.append(best_next)
selected_keys.add(best_key)
return selected
# =====================================================================
# 6. Full Experiment Pipeline & CLI Runner
# =====================================================================
def validate_cli_args(args: argparse.Namespace):
if args.pilot_epochs <= 0 or args.confirmation_epochs <= 0:
raise ValueError("Pilot and confirmation epochs must be positive integers.")
if args.pilot_particles <= 0 or args.confirmation_particles <= 0:
raise ValueError("Pilot and confirmation particles must be positive integers.")
if any(s < 0 for s in args.seeds):
raise ValueError("Seeds must be non-negative integers.")
if len(args.seeds) != len(set(args.seeds)):
raise ValueError("Confirmation seeds must be unique.")
max_d = 9098
for d_str in args.dimensions:
if d_str.lower() != "full":
try:
d_val = int(d_str)
if d_val <= 0 or d_val > max_d:
raise ValueError(f"Latent dimension {d_val} must be in range [1, {max_d}].")
except ValueError:
raise ValueError(f"Invalid dimension specifier: {d_str}")
if args.confirmation_schedule:
stages = args.confirmation_schedule.split(",")
total_sched_epochs = 0
known_sizes = {2000, 10000, 50000}
for s in stages:
sz_str, ep_str = s.split(":")
sz, ep = int(sz_str), int(ep_str)
if sz not in known_sizes:
raise ValueError(f"Unknown schedule subset size {sz}; known sizes are {known_sizes}.")
if ep <= 0:
raise ValueError(f"Schedule epochs must be positive; got {ep}.")
total_sched_epochs += ep
if total_sched_epochs != args.confirmation_epochs:
raise ValueError(
f"Schedule epoch sum ({total_sched_epochs}) must equal confirmation_epochs ({args.confirmation_epochs})."
)
def run_deep_pso_study(args: argparse.Namespace) -> Dict[str, Any]:
validate_cli_args(args)
device = resolve_execution_device(args.device)
print(f"=== MNIST Deep PSO Methods Study (Protocol {PROTOCOL_VERSION}) ===")
print(f"Device: {device}")
# Data preparation
(
x_search, y_search,
x_val, y_val,
x_test, y_test,
nested_subsets,
data_fp, provenance
) = prepare_mnist_v5_data(split_seed=args.split_seed)
base_model = make_compact_cnn(seed=41).to(device)
base_fp = compute_model_fingerprint(base_model)
hardware_prov = get_hardware_provenance(device)
failure_record: Optional[str] = None
# Pilot Phase: Validation-only selection of latent dimension
print("\n--- Pilot Phase (Dimension Selection on 10k Validation Set) ---")
pilot_results = []
best_pilot_dim = None
best_pilot_val_loss = float("inf")
best_pilot_val_acc = 0.0
dimensions = args.dimensions
for dim_str in dimensions:
print(f"Running Pilot: dim={dim_str}, seed={args.pilot_seed}, particles={args.pilot_particles}, epochs={args.pilot_epochs}")
transform = LatentTransform(base_model, latent_dim=dim_str, device=device)
res = run_latent_pso(
transform=transform,
base_model=base_model,
x_search=x_search,
y_search=y_search,
nested_subsets=nested_subsets,
schedule_str=f"2000:{args.pilot_epochs}",
epochs=args.pilot_epochs,
swarm_size=args.pilot_particles,
seed=args.pilot_seed,
device=device,
)
model = make_compact_cnn(seed=41).to(device)
transform.load_vector_to_model(transform.decode(res["gbest_z"].unsqueeze(0)).squeeze(0), model)
probs_val = get_model_probabilities(model, x_val, device)
val_metrics = evaluate_probabilistic_metrics(probs_val, y_val)
pilot_record = {
"dimension": str(dim_str),
"val_loss": val_metrics["nll"],
"val_acc": val_metrics["accuracy"],
"wall_time_sec": res["wall_time_sec"],
"queries": res["total_queries"],
"sample_evaluations": res["total_sample_evaluations"],
}
pilot_results.append(pilot_record)
print(f"Pilot dim={dim_str} -> Val Loss: {val_metrics['nll']:.6f}, Val Acc: {val_metrics['accuracy']:.2f}%")
if (val_metrics["nll"] < best_pilot_val_loss - 1e-7) or (abs(val_metrics["nll"] - best_pilot_val_loss) <= 1e-7 and val_metrics["accuracy"] > best_pilot_val_acc):
best_pilot_val_loss = val_metrics["nll"]
best_pilot_val_acc = val_metrics["accuracy"]
best_pilot_dim = dim_str
print(f"\nSelected Pilot Dimension: {best_pilot_dim} (Val Loss: {best_pilot_val_loss:.6f}, Val Acc: {best_pilot_val_acc:.2f}%)")
# Confirmation Phase: Run multi-seed progressive PSO on selected dimension
print(f"\n--- Confirmation Phase (Dimension={best_pilot_dim}, Seeds={args.seeds}) ---")
confirmation_runs = []
val_candidate_pool = []
conf_transform = LatentTransform(base_model, latent_dim=best_pilot_dim, device=device)
for c_seed in args.seeds:
print(f"Running Confirmation: seed={c_seed}, particles={args.confirmation_particles}, epochs={args.confirmation_epochs}, schedule={args.confirmation_schedule}")
res = run_latent_pso(
transform=conf_transform,
base_model=base_model,
x_search=x_search,
y_search=y_search,
nested_subsets=nested_subsets,
schedule_str=args.confirmation_schedule,
epochs=args.confirmation_epochs,
swarm_size=args.confirmation_particles,
seed=c_seed,
device=device,
)
# Evaluate EVERY particle's pbest on the 10k VALIDATION set
P_final = res["final_P"]
run_best_val_loss = float("inf")
run_best_val_acc = 0.0
for p_idx in range(len(P_final)):
p_z = P_final[p_idx]
cand_model = make_compact_cnn(seed=41).to(device)
conf_transform.load_vector_to_model(conf_transform.decode(p_z.unsqueeze(0)).squeeze(0), cand_model)
probs_val = get_model_probabilities(cand_model, x_val, device)
val_metrics = evaluate_probabilistic_metrics(probs_val, y_val)
cand_rec = {
"seed": c_seed,
"particle_idx": p_idx,
"val_loss": val_metrics["nll"],
"val_acc": val_metrics["accuracy"],
"val_probs": probs_val,
"latent_z": p_z,
}
val_candidate_pool.append(cand_rec)
if (val_metrics["nll"] < run_best_val_loss - 1e-7) or (abs(val_metrics["nll"] - run_best_val_loss) <= 1e-7 and val_metrics["accuracy"] > run_best_val_acc):
run_best_val_loss = val_metrics["nll"]
run_best_val_acc = val_metrics["accuracy"]
conf_record = {
"seed": c_seed,
"val_loss": run_best_val_loss,
"val_acc": run_best_val_acc,
"wall_time_sec": res["wall_time_sec"],
"queries": res["total_queries"],
"sample_evaluations": res["total_sample_evaluations"],
"transition_reevaluations": res["transition_reevaluation_counts"],
"stage_histories": res["stage_histories"],
}
confirmation_runs.append(conf_record)
print(f"Confirmation seed={c_seed} -> Best Val Loss: {run_best_val_loss:.6f}, Best Val Acc: {run_best_val_acc:.2f}%")
# Selection on Validation ONLY:
# 1. Single Final Model (lowest val NLL, then highest val Acc)
val_candidate_pool.sort(key=lambda c: (c["val_loss"], -c["val_acc"]))
best_single_candidate = val_candidate_pool[0]
# 2. Predeclared validation-performing, prediction-diverse Top-5 Ensemble
top_ensemble_candidates = select_diverse_candidates(val_candidate_pool)
# Official 10k Test Set Evaluation (EXACTLY ONCE PER ENDPOINT)
print("\n--- Final Official 10k Test Evaluation ---")
# Single Final Model Test Evaluation
single_model = make_compact_cnn(seed=41).to(device)
conf_transform.load_vector_to_model(conf_transform.decode(best_single_candidate["latent_z"].unsqueeze(0)).squeeze(0), single_model)
single_test_probs = get_model_probabilities(single_model, x_test, device)
single_test_metrics = evaluate_probabilistic_metrics(single_test_probs, y_test)
single_model_fp = compute_model_fingerprint(single_model)
print(f"Final Single Model (Seed {best_single_candidate['seed']}, Part {best_single_candidate['particle_idx']}) -> Test Acc: {single_test_metrics['accuracy']:.2f}%, Test NLL: {single_test_metrics['nll']:.6f}")
# Top Ensemble Test Evaluation
ensemble_test_probs_list = []
ensemble_preds_list = []
for cand in top_ensemble_candidates:
cand_model = make_compact_cnn(seed=41).to(device)
conf_transform.load_vector_to_model(conf_transform.decode(cand["latent_z"].unsqueeze(0)).squeeze(0), cand_model)
t_probs = get_model_probabilities(cand_model, x_test, device)
ensemble_test_probs_list.append(t_probs)
ensemble_preds_list.append(t_probs.argmax(dim=1).cpu().numpy())
ensemble_mean_probs = torch.stack(ensemble_test_probs_list).mean(dim=0)
ensemble_test_metrics = evaluate_probabilistic_metrics(ensemble_mean_probs, y_test)
ensemble_disagreement = compute_pairwise_disagreement(ensemble_preds_list)
print(f"Top-{len(top_ensemble_candidates)} Ensemble -> Test Acc: {ensemble_test_metrics['accuracy']:.2f}%, Test NLL: {ensemble_test_metrics['nll']:.6f}, Disagreement: {ensemble_disagreement:.6f}")
ensemble_val_probs = torch.stack(
[cand["val_probs"] for cand in top_ensemble_candidates]
).mean(dim=0)
ensemble_val_metrics = evaluate_probabilistic_metrics(
ensemble_val_probs, y_val
)
ensemble_val_disagreement = compute_pairwise_disagreement(
[
cand["val_probs"].argmax(dim=1).cpu().numpy()
for cand in top_ensemble_candidates
]
)
total_wall_time = sum(c["wall_time_sec"] for c in confirmation_runs) + sum(p["wall_time_sec"] for p in pilot_results)
total_queries_all = sum(c["queries"] for c in confirmation_runs) + sum(p["queries"] for p in pilot_results)
total_samples_all = sum(c["sample_evaluations"] for c in confirmation_runs) + sum(p["sample_evaluations"] for p in pilot_results)
final_payload = {
"protocol_version": PROTOCOL_VERSION,
"pso_version": pso_version,
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"completed": True,
"hardware_provenance": hardware_prov,
"data_provenance": provenance,
"data_fingerprint": data_fp,
"base_model_fingerprint": base_fp,
"configuration": {
"base_model_seed": 41,
"split_seed": args.split_seed,
"pilot_seed": args.pilot_seed,
"pilot_dimensions": [str(dim) for dim in args.dimensions],
"pilot_particles": args.pilot_particles,
"pilot_epochs": args.pilot_epochs,
"confirmation_seeds": args.seeds,
"confirmation_particles": args.confirmation_particles,
"confirmation_epochs": args.confirmation_epochs,
"confirmation_schedule": args.confirmation_schedule,
"fitness_objective": "cross_entropy_loss_primary_accuracy_tiebreak",
"parameterization": {
"layer_scale": "per_parameter_tensor_std_floor_1e-4",
"subspace": "deterministic_sparse_signed_hash_count_normalized",
"initialization": "exact_base_plus_antithetic",
"initial_radius": 0.5,
"reflective_bound": 3.0,
},
"movement": {
"name": "latent_adaptive_moment_pso",
"c0": 1.49618,
"c1": 1.49618,
"w": 0.7298,
"moment_blend": 0.06,
"moment_step_size": 0.5,
"moment_beta1": 0.9,
"moment_beta2": 0.999,
},
"objective_transition": "reevaluate_all_pbests_then_rebuild_gbest",
"validation_selection": {
"single": "lowest_nll_then_highest_accuracy",
"ensemble": "within_2_accuracy_points_then_greedy_disagreement",
"ensemble_size": 5,
},
"fitness_batch_size": 1000,
"ece_bins": 15,
},
"pilot_phase": {
"selected_dimension": str(best_pilot_dim),
"results": pilot_results,
},
"confirmation_phase": {
"runs": [
{
"seed": r["seed"],
"val_loss": r["val_loss"],
"val_acc": r["val_acc"],
"wall_time_sec": r["wall_time_sec"],
"queries": r["queries"],
"sample_evaluations": r["sample_evaluations"],
"transition_reevaluations": r["transition_reevaluations"],
"stage_histories": r["stage_histories"],
}
for r in confirmation_runs
],
"validation_summary": {
"best_pbest_nll": calc_stats(
[run["val_loss"] for run in confirmation_runs]
),
"best_pbest_accuracy": calc_stats(
[run["val_acc"] for run in confirmation_runs]
),
"wall_time_sec": calc_stats(
[run["wall_time_sec"] for run in confirmation_runs]
),
},
},
"final_endpoints": {
"single_model": {
"selected_seed": best_single_candidate["seed"],
"selected_particle_idx": best_single_candidate["particle_idx"],
"model_fingerprint": single_model_fp,
"val_loss": best_single_candidate["val_loss"],
"val_acc": best_single_candidate["val_acc"],
"selection_rule": "lowest_validation_nll_then_highest_accuracy",
"test_accuracy": single_test_metrics["accuracy"],
"test_nll": single_test_metrics["nll"],
"test_brier": single_test_metrics["brier"],
"test_ece": single_test_metrics["ece"],
"test_margin": single_test_metrics["margin"],
},
"ensemble": {
"ensemble_size": len(top_ensemble_candidates),
"members": [
{
"seed": cand["seed"],
"particle_idx": cand["particle_idx"],
"val_loss": cand["val_loss"],
"val_acc": cand["val_acc"],
}
for cand in top_ensemble_candidates
],
"validation_accuracy": ensemble_val_metrics["accuracy"],
"validation_nll": ensemble_val_metrics["nll"],
"validation_brier": ensemble_val_metrics["brier"],
"validation_ece": ensemble_val_metrics["ece"],
"validation_margin": ensemble_val_metrics["margin"],
"validation_pairwise_disagreement": ensemble_val_disagreement,
"test_accuracy": ensemble_test_metrics["accuracy"],
"test_nll": ensemble_test_metrics["nll"],
"test_brier": ensemble_test_metrics["brier"],
"test_ece": ensemble_test_metrics["ece"],
"test_margin": ensemble_test_metrics["margin"],
"pairwise_disagreement": ensemble_disagreement,
},
},
"accounting": {
"total_wall_time_sec": round(total_wall_time, 4),
"total_queries": total_queries_all,
"total_sample_evaluations": total_samples_all,
"scope": (
"pilot_and_confirmation_training_objectives_including_"
"transition_reevaluations; excludes validation and test"
),
},
"failure_record": failure_record,
}
# Save atomic JSON
json_path = Path(args.json_path)
save_json_atomic(final_payload, json_path)
print(f"Saved atomic JSON to {json_path}")
# Save CSV
csv_path = Path(args.csv_path)
save_csv_summary(final_payload, csv_path)
print(f"Saved CSV summary to {csv_path}")
# Save PNG plot
plot_path = Path(args.plot_path)
generate_study_plots(final_payload, plot_path)
print(f"Saved PNG plot to {plot_path}")
return final_payload
def get_model_probabilities(model: nn.Module, x_data: torch.Tensor, device: torch.device, batch_size: int = 1000) -> torch.Tensor:
model.eval()
prob_list = []
num_samples = len(x_data)
with torch.inference_mode():
for b_start in range(0, num_samples, batch_size):
xb = x_data[b_start:b_start + batch_size].to(device)
logits = model(xb)
probs = torch.softmax(logits, dim=1)
prob_list.append(probs)
return torch.cat(prob_list, dim=0)
def save_csv_summary(payload: Dict[str, Any], csv_path: Path):
csv_path.parent.mkdir(parents=True, exist_ok=True)
with open(csv_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["section", "metric", "value"])
writer.writerow(["protocol", "version", payload["protocol_version"]])
pilot = payload["pilot_phase"]
writer.writerow(["pilot", "selected_dimension", pilot["selected_dimension"]])
single = payload["final_endpoints"]["single_model"]
writer.writerow(["single_model", "test_accuracy", single["test_accuracy"]])
writer.writerow(["single_model", "test_nll", single["test_nll"]])
writer.writerow(["single_model", "test_brier", single["test_brier"]])
writer.writerow(["single_model", "test_ece", single["test_ece"]])
ens = payload["final_endpoints"]["ensemble"]
writer.writerow(["ensemble", "test_accuracy", ens["test_accuracy"]])
writer.writerow(["ensemble", "test_nll", ens["test_nll"]])
writer.writerow(["ensemble", "pairwise_disagreement", ens["pairwise_disagreement"]])
def generate_study_plots(payload: Dict[str, Any], plot_path: Path):
plot_path.parent.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Panel 1: Pilot Dimension Selection
pilot_results = payload["pilot_phase"]["results"]
dims = [r["dimension"] for r in pilot_results]
val_losses = [r["val_loss"] for r in pilot_results]
val_accs = [r["val_acc"] for r in pilot_results]
ax1 = axes[0]
ax1_twin = ax1.twinx()
b1 = ax1.bar(np.arange(len(dims)) - 0.2, val_losses, width=0.4, color="#56B4E9", label="Val NLL")
b2 = ax1_twin.bar(np.arange(len(dims)) + 0.2, val_accs, width=0.4, color="#009E73", label="Val Acc (%)")
ax1.set_xticks(range(len(dims)))
ax1.set_xticklabels(dims)
ax1.set_xlabel("Latent Dimension")
ax1.set_ylabel("Validation NLL")
ax1_twin.set_ylabel("Validation Accuracy (%)")
ax1.set_title("Pilot Dimension Selection")
# Panel 2: Confirmation Training Histories
ax2 = axes[1]
conf_runs = payload["confirmation_phase"]["runs"]
for run in conf_runs:
hist = run["stage_histories"]
epochs = [h["epoch"] for h in hist]
losses = [h["gbest_loss"] for h in hist]
ax2.plot(epochs, losses, label=f"Seed {run['seed']}")
ax2.set_xlabel("Epoch")
ax2.set_ylabel("Gbest CE Loss")
ax2.set_title("Confirmation Stage Training Histories")
ax2.legend()
ax2.grid(True, linestyle="--", alpha=0.5)
# Panel 3: Final Endpoint Comparison
ax3 = axes[2]
single_acc = payload["final_endpoints"]["single_model"]["test_accuracy"]
ens_acc = payload["final_endpoints"]["ensemble"]["test_accuracy"]
single_nll = payload["final_endpoints"]["single_model"]["test_nll"]
ens_nll = payload["final_endpoints"]["ensemble"]["test_nll"]
x_labels = ["Single Model", "Top-5 Ensemble"]
accs = [single_acc, ens_acc]
nlls = [single_nll, ens_nll]
ax3_twin = ax3.twinx()
ax3.bar(np.arange(2) - 0.15, accs, width=0.3, color="#CC79A7", label="Test Acc (%)")
ax3_twin.bar(np.arange(2) + 0.15, nlls, width=0.3, color="#D55E00", label="Test NLL")
ax3.set_xticks(range(2))
ax3.set_xticklabels(x_labels)
ax3.set_ylabel("Test Accuracy (%)")
ax3_twin.set_ylabel("Test NLL")
ax3.set_title("Final Official Test Endpoints")
plt.tight_layout()
plt.savefig(plot_path, dpi=300)
plt.close(fig)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="MNIST Deep PSO Methods Study (V5)")
parser.add_argument("--pilot-epochs", type=int, default=160)
parser.add_argument("--pilot-particles", type=int, default=30)
parser.add_argument("--pilot-seed", type=int, default=91)
parser.add_argument("--split-seed", type=int, default=20260902)
parser.add_argument("--confirmation-epochs", type=int, default=600)
parser.add_argument("--confirmation-particles", type=int, default=60)
parser.add_argument("--confirmation-schedule", type=str, default="2000:420,10000:135,50000:45")
parser.add_argument("--seeds", nargs="+", type=int, default=[101, 102, 103])
parser.add_argument("--dimensions", nargs="+", type=str, default=["290", "1024", "4096", "full"])
parser.add_argument("--device", type=str, default=None)
parser.add_argument("--json-path", type=str, default="benchmark_results/pso_v5_deep_methods.json")
parser.add_argument("--csv-path", type=str, default="benchmark_results/pso_v5_deep_methods.csv")
parser.add_argument("--plot-path", type=str, default="history_plt/pso_v5_deep_methods.png")
return parser
if __name__ == "__main__":
parser = build_parser()
args = parser.parse_args()
run_deep_pso_study(args)