mirror of
https://github.com/jung-geun/PSO.git
synced 2026-09-20 14:11:48 +09:00
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
This commit is contained in:
+119
-53
@@ -1,72 +1,138 @@
|
||||
import os
|
||||
"""Dry Bean dataset gradient baseline (PyTorch standard backprop optimizer, non-PSO)."""
|
||||
|
||||
from keras.layers import Dense
|
||||
from keras.models import Sequential
|
||||
from keras.utils import to_categorical
|
||||
import copy
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
from sklearn.model_selection import train_test_split
|
||||
from tensorflow import keras
|
||||
from sklearn.preprocessing import LabelEncoder
|
||||
from ucimlrepo import fetch_ucirepo
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
|
||||
os.environ["TF_FORCE_GPU_ALLOW_GROWTH"] = "true"
|
||||
|
||||
class BeanModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(
|
||||
nn.Linear(16, 12),
|
||||
nn.ReLU(),
|
||||
nn.Linear(12, 8),
|
||||
nn.ReLU(),
|
||||
nn.Linear(8, 7),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net(x)
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(Dense(12, input_dim=16, activation="relu"))
|
||||
model.add(Dense(8, activation="relu"))
|
||||
model.add(Dense(7, activation="softmax"))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def get_data():
|
||||
# fetch dataset
|
||||
def get_data(seed: int = 42):
|
||||
dry_bean_dataset = fetch_ucirepo(id=602)
|
||||
|
||||
# data (as pandas dataframes)
|
||||
X = dry_bean_dataset.data.features
|
||||
y = dry_bean_dataset.data.targets
|
||||
|
||||
x = X.to_numpy()
|
||||
# object to categorical
|
||||
|
||||
x = x.astype("float32")
|
||||
|
||||
y_class = to_categorical(y)
|
||||
|
||||
# metadata
|
||||
# print(dry_bean_dataset.metadata)
|
||||
|
||||
# variable information
|
||||
# print(dry_bean_dataset.variables)
|
||||
|
||||
# print(X.head())
|
||||
# print(y.head())
|
||||
# y_class = to_categorical(y)
|
||||
x = X.to_numpy().astype("float32")
|
||||
encoder = LabelEncoder()
|
||||
y_encoded = encoder.fit_transform(y.values.ravel()).astype("int64")
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y_class, test_size=0.2, random_state=42, shuffle=True
|
||||
x, y_encoded, test_size=0.2, random_state=seed, shuffle=True
|
||||
)
|
||||
return (
|
||||
torch.tensor(x_train, dtype=torch.float32),
|
||||
torch.tensor(x_test, dtype=torch.float32),
|
||||
torch.tensor(y_train, dtype=torch.int64),
|
||||
torch.tensor(y_test, dtype=torch.int64),
|
||||
)
|
||||
return x_train, x_test, y_train, y_test
|
||||
|
||||
|
||||
x_train, x_test, y_train, y_test = get_data()
|
||||
model = make_model()
|
||||
early_stopping = keras.callbacks.EarlyStopping(
|
||||
patience=10, min_delta=0.001, restore_best_weights=True
|
||||
)
|
||||
def get_device() -> torch.device:
|
||||
if (
|
||||
hasattr(torch.backends, "mps")
|
||||
and torch.backends.mps.is_built()
|
||||
and torch.backends.mps.is_available()
|
||||
):
|
||||
return torch.device("mps")
|
||||
elif torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
model.compile(
|
||||
loss="sparse_categorical_crossentropy",
|
||||
optimizer="adam",
|
||||
metrics=["accuracy", "mse"],
|
||||
)
|
||||
def main():
|
||||
torch.manual_seed(42)
|
||||
np.random.seed(42)
|
||||
|
||||
model.summary()
|
||||
device = get_device()
|
||||
print(f"Selected device: {device}")
|
||||
|
||||
history = model.fit(
|
||||
x_train, y_train, epochs=150, batch_size=10, callbacks=[early_stopping]
|
||||
)
|
||||
score = model.evaluate(x_test, y_test, verbose=2)
|
||||
x_train, x_test, y_train, y_test = get_data(seed=42)
|
||||
train_dataset = TensorDataset(x_train, y_train)
|
||||
val_dataset = TensorDataset(x_test, y_test)
|
||||
train_loader = DataLoader(train_dataset, batch_size=10, shuffle=True)
|
||||
val_loader = DataLoader(val_dataset, batch_size=10, shuffle=False)
|
||||
|
||||
model = BeanModel().to(device)
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
best_val_loss = float("inf")
|
||||
best_state = None
|
||||
patience = 10
|
||||
min_delta = 0.001
|
||||
patience_counter = 0
|
||||
|
||||
for epoch in range(150):
|
||||
model.train()
|
||||
for bx, by in train_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
optimizer.zero_grad()
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
model.eval()
|
||||
val_loss = 0.0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in val_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
val_loss += loss.item() * bx.size(0)
|
||||
total += bx.size(0)
|
||||
|
||||
val_loss /= total
|
||||
|
||||
if val_loss < best_val_loss - min_delta:
|
||||
best_val_loss = val_loss
|
||||
best_state = copy.deepcopy(model.state_dict())
|
||||
patience_counter = 0
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= patience:
|
||||
break
|
||||
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
test_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in val_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
test_loss += loss.item() * bx.size(0)
|
||||
preds = out.argmax(dim=1)
|
||||
correct += (preds == by).sum().item()
|
||||
total += bx.size(0)
|
||||
|
||||
test_loss /= total
|
||||
test_acc = correct / total
|
||||
print(f"Final test loss: {test_loss:.4f}, accuracy: {test_acc:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+490
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Command-Line Interface Helpers for PSO Experiments.
|
||||
|
||||
Provides standard argparse argument groups and helper functions for PSO stage selectors
|
||||
(method, initialization, evaluation, convergence, refinement) and common execution parameters.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
# Supported stage plugin selector options
|
||||
STAGE_SELECTORS: dict[str, list[str]] = {
|
||||
"method": [
|
||||
"original",
|
||||
"inertia",
|
||||
"constriction",
|
||||
"fips",
|
||||
"clpso",
|
||||
"bare_bones",
|
||||
"adaptive_moment",
|
||||
],
|
||||
"initialization": ["model_noise", "uniform"],
|
||||
"evaluation": ["full", "fixed_subset"],
|
||||
"convergence": ["none", "particle_reset", "early_stopping"],
|
||||
"refinement": ["none", "adam"],
|
||||
}
|
||||
|
||||
|
||||
def add_stage_selector_args(
|
||||
parser: argparse.ArgumentParser, defaults: dict[str, Any] | None = None
|
||||
) -> argparse.ArgumentParser:
|
||||
"""Adds the 5 explicit stage selector arguments to an argparse parser."""
|
||||
defaults = defaults or {}
|
||||
|
||||
parser.add_argument(
|
||||
"--method",
|
||||
type=str,
|
||||
choices=STAGE_SELECTORS["method"],
|
||||
default=defaults.get("method", "original"),
|
||||
help="PSO movement stage method (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--initialization",
|
||||
type=str,
|
||||
choices=STAGE_SELECTORS["initialization"],
|
||||
default=defaults.get("initialization", "model_noise"),
|
||||
help="PSO particle initialization stage (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evaluation",
|
||||
type=str,
|
||||
choices=STAGE_SELECTORS["evaluation"],
|
||||
default=defaults.get("evaluation", "full"),
|
||||
help="PSO objective evaluation stage (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--convergence",
|
||||
type=str,
|
||||
choices=STAGE_SELECTORS["convergence"],
|
||||
default=defaults.get("convergence", "none"),
|
||||
help="PSO convergence behavior stage (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refinement",
|
||||
type=str,
|
||||
choices=STAGE_SELECTORS["refinement"],
|
||||
default=defaults.get("refinement", "none"),
|
||||
help="PSO post-search refinement stage (default: %(default)s)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def add_pso_args(
|
||||
parser: argparse.ArgumentParser, defaults: dict[str, Any] | None = None
|
||||
) -> argparse.ArgumentParser:
|
||||
"""Adds stage selectors and common PSO hyperparameter arguments to an argparse parser."""
|
||||
defaults = defaults or {}
|
||||
|
||||
# Add 5 stage selectors
|
||||
add_stage_selector_args(parser, defaults)
|
||||
|
||||
# Core execution and hyperparameter options
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=defaults.get("seed", 42),
|
||||
help="Random seed for reproducibility (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default=defaults.get("device", None),
|
||||
help="Execution target device (cpu, cuda, mps) (default: auto)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--n-particles",
|
||||
"--particles",
|
||||
dest="n_particles",
|
||||
type=int,
|
||||
default=defaults.get("n_particles", 30),
|
||||
help="Number of swarm particles (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs",
|
||||
type=int,
|
||||
default=defaults.get("epochs", 80),
|
||||
help="Number of PSO optimization epochs (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
"--batch",
|
||||
dest="batch_size",
|
||||
type=int,
|
||||
default=defaults.get("batch_size", None),
|
||||
help="Batch size for objective evaluation (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fitness-size",
|
||||
type=int,
|
||||
default=defaults.get("fitness_size", None),
|
||||
help="Fixed subset sample count (required when evaluation='fixed_subset')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refinement-epochs",
|
||||
type=int,
|
||||
default=defaults.get("refinement_epochs", 0),
|
||||
help="Refinement epoch count (required when refinement='adam')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refinement-lr",
|
||||
type=float,
|
||||
default=defaults.get("refinement_lr", 0.001),
|
||||
help="Refinement learning rate for Adam optimizer (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--renewal",
|
||||
type=str,
|
||||
choices=["acc", "loss", "mse"],
|
||||
default=defaults.get("renewal", "loss"),
|
||||
help="Primary metric for global best selection (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=str,
|
||||
default=defaults.get("output_dir", None),
|
||||
help="Directory to save model checkpoints and logs",
|
||||
)
|
||||
|
||||
# Optional coefficient overrides
|
||||
parser.add_argument(
|
||||
"--c0",
|
||||
type=float,
|
||||
default=defaults.get("c0", None),
|
||||
help="Cognitive acceleration coefficient override",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--c1",
|
||||
type=float,
|
||||
default=defaults.get("c1", None),
|
||||
help="Social acceleration coefficient override",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--w-min",
|
||||
dest="w_min",
|
||||
type=float,
|
||||
default=defaults.get("w_min", None),
|
||||
help="Minimum inertia weight override",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--w-max",
|
||||
dest="w_max",
|
||||
type=float,
|
||||
default=defaults.get("w_max", None),
|
||||
help="Maximum inertia weight override",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--negative-swarm",
|
||||
type=float,
|
||||
default=defaults.get("negative_swarm", 0.0),
|
||||
help="Negative swarm velocity coefficient (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mutation-swarm",
|
||||
type=float,
|
||||
default=defaults.get("mutation_swarm", 0.0),
|
||||
help="Swarm mutation probability (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--particle-min",
|
||||
type=float,
|
||||
default=defaults.get("particle_min", None),
|
||||
help="Lower bound for particle position clamping",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--particle-max",
|
||||
type=float,
|
||||
default=defaults.get("particle_max", None),
|
||||
help="Upper bound for particle position clamping",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--velocity-limit-ratio",
|
||||
type=float,
|
||||
default=defaults.get("velocity_limit_ratio", None),
|
||||
help="Maximum velocity limit ratio relative to search domain",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--boundary-strategy",
|
||||
type=str,
|
||||
choices=["clip", "reflect"],
|
||||
default=defaults.get("boundary_strategy", "clip"),
|
||||
help="Position boundary handling strategy (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--initial-position-noise",
|
||||
type=float,
|
||||
default=defaults.get("initial_position_noise", 0.05),
|
||||
help="Initial position noise scale (default: %(default)s)",
|
||||
)
|
||||
# Convergence stage options
|
||||
parser.add_argument(
|
||||
"--convergence-patience",
|
||||
dest="convergence_patience",
|
||||
type=int,
|
||||
default=defaults.get("convergence_patience", 10),
|
||||
help="Convergence reset/early stopping patience epochs (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--convergence-min-delta",
|
||||
dest="convergence_min_delta",
|
||||
type=float,
|
||||
default=defaults.get("convergence_min_delta", 0.0001),
|
||||
help="Minimum improvement delta for convergence (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--convergence-monitor",
|
||||
dest="convergence_monitor",
|
||||
type=str,
|
||||
choices=["loss", "acc", "accuracy", "mse"],
|
||||
default=defaults.get("convergence_monitor", "loss"),
|
||||
help="Metric monitored for convergence (default: %(default)s)",
|
||||
)
|
||||
|
||||
# Adaptive moment options
|
||||
parser.add_argument(
|
||||
"--moment-blend",
|
||||
dest="moment_blend",
|
||||
type=float,
|
||||
default=defaults.get("moment_blend", None),
|
||||
help="Adaptive moment blend factor (default: 0.25 when method='adaptive_moment', else 0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--moment-beta1",
|
||||
dest="moment_beta1",
|
||||
type=float,
|
||||
default=defaults.get("moment_beta1", None),
|
||||
help="Adaptive moment beta1 parameter (default: 0.9 when method='adaptive_moment')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--moment-beta2",
|
||||
dest="moment_beta2",
|
||||
type=float,
|
||||
default=defaults.get("moment_beta2", None),
|
||||
help="Adaptive moment beta2 parameter (default: 0.999 when method='adaptive_moment')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--moment-step-size",
|
||||
dest="moment_step_size",
|
||||
type=float,
|
||||
default=defaults.get("moment_step_size", None),
|
||||
help="Adaptive moment step size (default: 1.0 when method='adaptive_moment')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--moment-epsilon",
|
||||
dest="moment_epsilon",
|
||||
type=float,
|
||||
default=defaults.get("moment_epsilon", None),
|
||||
help="Adaptive moment epsilon parameter (default: 1e-8 when method='adaptive_moment')",
|
||||
)
|
||||
|
||||
# Repeatable method options
|
||||
parser.add_argument(
|
||||
"--method-option",
|
||||
dest="method_options",
|
||||
action="append",
|
||||
metavar="KEY=VALUE",
|
||||
help="Additional key=value option for movement method (repeatable)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def parse_method_options(options: list[str] | None) -> dict[str, Any]:
|
||||
"""Parses a list of 'KEY=VALUE' strings into a dictionary with typed values."""
|
||||
res: dict[str, Any] = {}
|
||||
if not options:
|
||||
return res
|
||||
for opt in options:
|
||||
if "=" not in opt:
|
||||
raise ValueError(f"Invalid --method-option format '{opt}', expected 'KEY=VALUE'")
|
||||
key, val = opt.split("=", 1)
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
val_lower = val.lower()
|
||||
if val_lower == "true":
|
||||
parsed_val: Any = True
|
||||
elif val_lower == "false":
|
||||
parsed_val = False
|
||||
else:
|
||||
try:
|
||||
parsed_val = int(val)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed_val = float(val)
|
||||
except ValueError:
|
||||
parsed_val = val
|
||||
res[key] = parsed_val
|
||||
return res
|
||||
|
||||
|
||||
def build_optimizer_kwargs(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
model: Any = None,
|
||||
loss: Any = None,
|
||||
task: str | None = None,
|
||||
inertia_profile: dict[str, float] | None = None,
|
||||
**extra_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Builds Optimizer constructor keyword arguments from parsed CLI arguments.
|
||||
|
||||
Applies method-specific parameter compatibility rules, workload inertia profiles
|
||||
(only when method='inertia'), repeatable method options (--method-option), and
|
||||
adaptive moment flags (only when method='adaptive_moment').
|
||||
"""
|
||||
method = getattr(args, "method", "original")
|
||||
parsed_method_opts = parse_method_options(getattr(args, "method_options", None))
|
||||
|
||||
if method == "inertia":
|
||||
profile = inertia_profile or {}
|
||||
c0 = (
|
||||
parsed_method_opts["c0"]
|
||||
if "c0" in parsed_method_opts
|
||||
else (args.c0 if getattr(args, "c0", None) is not None else profile.get("c0"))
|
||||
)
|
||||
c1 = (
|
||||
parsed_method_opts["c1"]
|
||||
if "c1" in parsed_method_opts
|
||||
else (args.c1 if getattr(args, "c1", None) is not None else profile.get("c1"))
|
||||
)
|
||||
w_min = (
|
||||
parsed_method_opts["w_min"]
|
||||
if "w_min" in parsed_method_opts
|
||||
else (args.w_min if getattr(args, "w_min", None) is not None else profile.get("w_min"))
|
||||
)
|
||||
w_max = (
|
||||
parsed_method_opts["w_max"]
|
||||
if "w_max" in parsed_method_opts
|
||||
else (args.w_max if getattr(args, "w_max", None) is not None else profile.get("w_max"))
|
||||
)
|
||||
elif method in ("original", "constriction", "fips"):
|
||||
c0 = (
|
||||
parsed_method_opts["c0"]
|
||||
if "c0" in parsed_method_opts
|
||||
else getattr(args, "c0", None)
|
||||
)
|
||||
c1 = (
|
||||
parsed_method_opts["c1"]
|
||||
if "c1" in parsed_method_opts
|
||||
else getattr(args, "c1", None)
|
||||
)
|
||||
w_min = None
|
||||
w_max = None
|
||||
elif method == "bare_bones":
|
||||
c0 = None
|
||||
c1 = None
|
||||
w_min = None
|
||||
w_max = None
|
||||
else:
|
||||
c0 = (
|
||||
parsed_method_opts["c0"]
|
||||
if "c0" in parsed_method_opts
|
||||
else getattr(args, "c0", None)
|
||||
)
|
||||
c1 = (
|
||||
parsed_method_opts["c1"]
|
||||
if "c1" in parsed_method_opts
|
||||
else getattr(args, "c1", None)
|
||||
)
|
||||
w_min = (
|
||||
parsed_method_opts["w_min"]
|
||||
if "w_min" in parsed_method_opts
|
||||
else getattr(args, "w_min", None)
|
||||
)
|
||||
w_max = (
|
||||
parsed_method_opts["w_max"]
|
||||
if "w_max" in parsed_method_opts
|
||||
else getattr(args, "w_max", None)
|
||||
)
|
||||
|
||||
if method in ("fips", "clpso", "bare_bones"):
|
||||
neg_swarm = 0.0
|
||||
else:
|
||||
neg_swarm = (
|
||||
float(parsed_method_opts["negative_swarm"])
|
||||
if "negative_swarm" in parsed_method_opts
|
||||
else float(getattr(args, "negative_swarm", 0.0))
|
||||
)
|
||||
|
||||
if method == "bare_bones":
|
||||
mut_swarm = 0.0
|
||||
else:
|
||||
mut_swarm = (
|
||||
float(parsed_method_opts["mutation_swarm"])
|
||||
if "mutation_swarm" in parsed_method_opts
|
||||
else float(getattr(args, "mutation_swarm", 0.0))
|
||||
)
|
||||
|
||||
if method == "bare_bones":
|
||||
vel_ratio = None
|
||||
else:
|
||||
vel_ratio = (
|
||||
parsed_method_opts["velocity_limit_ratio"]
|
||||
if "velocity_limit_ratio" in parsed_method_opts
|
||||
else getattr(args, "velocity_limit_ratio", None)
|
||||
)
|
||||
|
||||
fitness_size = (
|
||||
getattr(args, "fitness_size", None)
|
||||
if getattr(args, "evaluation", None) == "fixed_subset"
|
||||
else None
|
||||
)
|
||||
refinement_epochs = (
|
||||
getattr(args, "refinement_epochs", 0)
|
||||
if getattr(args, "refinement", None) == "adam"
|
||||
else 0
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"loss": loss,
|
||||
"task": task,
|
||||
"method": method,
|
||||
"initialization": getattr(args, "initialization", "model_noise"),
|
||||
"evaluation": getattr(args, "evaluation", "full"),
|
||||
"convergence": getattr(args, "convergence", "none"),
|
||||
"refinement": getattr(args, "refinement", "none"),
|
||||
"method_options": parsed_method_opts,
|
||||
"n_particles": getattr(args, "n_particles", 30),
|
||||
"c0": c0,
|
||||
"c1": c1,
|
||||
"w_min": w_min,
|
||||
"w_max": w_max,
|
||||
"negative_swarm": neg_swarm,
|
||||
"mutation_swarm": mut_swarm,
|
||||
"particle_min": getattr(args, "particle_min", None),
|
||||
"particle_max": getattr(args, "particle_max", None),
|
||||
"velocity_limit_ratio": vel_ratio,
|
||||
"boundary_strategy": getattr(args, "boundary_strategy", "clip"),
|
||||
"initial_position_noise": getattr(args, "initial_position_noise", 0.05),
|
||||
"seed": getattr(args, "seed", None),
|
||||
"device": getattr(args, "device", None),
|
||||
"fitness_size": fitness_size,
|
||||
"convergence_patience": getattr(args, "convergence_patience", 10),
|
||||
"convergence_min_delta": getattr(args, "convergence_min_delta", 0.0001),
|
||||
"convergence_monitor": getattr(args, "convergence_monitor", "loss"),
|
||||
"refinement_epochs": refinement_epochs,
|
||||
"refinement_lr": getattr(args, "refinement_lr", 0.001),
|
||||
}
|
||||
|
||||
if method == "adaptive_moment":
|
||||
if "moment_blend" in parsed_method_opts:
|
||||
m_blend = float(parsed_method_opts["moment_blend"])
|
||||
elif getattr(args, "moment_blend", None) is not None:
|
||||
m_blend = float(getattr(args, "moment_blend"))
|
||||
else:
|
||||
m_blend = 0.25
|
||||
kwargs["moment_blend"] = m_blend
|
||||
|
||||
for param_name in (
|
||||
"moment_beta1",
|
||||
"moment_beta2",
|
||||
"moment_step_size",
|
||||
"moment_epsilon",
|
||||
):
|
||||
if param_name in parsed_method_opts:
|
||||
kwargs[param_name] = float(parsed_method_opts[param_name])
|
||||
elif getattr(args, param_name, None) is not None:
|
||||
kwargs[param_name] = float(getattr(args, param_name))
|
||||
|
||||
kwargs.update(extra_kwargs)
|
||||
return kwargs
|
||||
Executable
+427
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PSO Stage Plugin Method Comparison Tool.
|
||||
|
||||
Compares PSO movement methods (original, inertia, constriction, fips, clpso, bare_bones,
|
||||
adaptive_moment, local_best, quantum)
|
||||
across benchmark datasets (xor, iris, mnist) with reproducible model initialization, dataset splits,
|
||||
and clean console output & JSON result reporting.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from cli import add_pso_args, parse_method_options
|
||||
from pso import Optimizer
|
||||
from pso.plugins import available_plugins
|
||||
|
||||
|
||||
def print_available_methods():
|
||||
"""Prints available movement method plugins and metadata provenance."""
|
||||
movement_plugins = available_plugins("movement")
|
||||
print("Available PSO Movement Method Plugins:")
|
||||
print("=" * 80)
|
||||
for name, meta in movement_plugins.items():
|
||||
print(f" Stage Key : {name}")
|
||||
print(f" Title : {meta.title}")
|
||||
print(f" Source (DOI) : {meta.source or 'N/A'}")
|
||||
print(f" Fidelity : {meta.fidelity}")
|
||||
print(f" Needs Grad : {meta.gradient_required}")
|
||||
print("-" * 80)
|
||||
|
||||
|
||||
def get_xor_workload(seed: int):
|
||||
"""Builds identical XOR dataset and PyTorch model state for a given seed."""
|
||||
torch.manual_seed(seed)
|
||||
x_train = torch.tensor(
|
||||
[[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]], dtype=torch.float32
|
||||
)
|
||||
y_train = torch.tensor([[0.0], [1.0], [1.0], [0.0]], dtype=torch.float32)
|
||||
|
||||
model = nn.Sequential(
|
||||
nn.Linear(2, 4),
|
||||
nn.Tanh(),
|
||||
nn.Linear(4, 1),
|
||||
)
|
||||
loss_fn = nn.BCEWithLogitsLoss()
|
||||
return x_train, y_train, None, model, loss_fn, "binary"
|
||||
|
||||
|
||||
def get_iris_workload(seed: int):
|
||||
"""Builds identical Iris dataset and PyTorch model state for a given seed."""
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
torch.manual_seed(seed)
|
||||
iris = load_iris()
|
||||
X = iris.data.astype("float32")
|
||||
y = iris.target.astype("int64")
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=0.2, shuffle=True, stratify=y, random_state=seed
|
||||
)
|
||||
scaler = StandardScaler()
|
||||
x_train = scaler.fit_transform(x_train)
|
||||
x_test = scaler.transform(x_test)
|
||||
|
||||
x_tr = torch.tensor(x_train, dtype=torch.float32)
|
||||
y_tr = torch.tensor(y_train, dtype=torch.int64)
|
||||
x_te = torch.tensor(x_test, dtype=torch.float32)
|
||||
y_te = torch.tensor(y_test, dtype=torch.int64)
|
||||
|
||||
model = nn.Sequential(
|
||||
nn.Linear(4, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 3),
|
||||
)
|
||||
loss_fn = nn.CrossEntropyLoss()
|
||||
return x_tr, y_tr, (x_te, y_te), model, loss_fn, "multiclass"
|
||||
|
||||
|
||||
def get_mnist_workload(seed: int):
|
||||
"""Builds identical PCA32 MNIST dataset and PyTorch model state for a given seed."""
|
||||
from sklearn.decomposition import PCA
|
||||
from torchvision.datasets import MNIST
|
||||
|
||||
torch.manual_seed(seed)
|
||||
train_ds = MNIST(root="./data", train=True, download=True)
|
||||
test_ds = MNIST(root="./data", train=False, download=True)
|
||||
|
||||
x_tr_raw = (train_ds.data[:3000].float() / 255.0).reshape(3000, -1).numpy()
|
||||
y_tr = train_ds.targets[:3000].long()
|
||||
x_te_raw = (test_ds.data[:1000].float() / 255.0).reshape(1000, -1).numpy()
|
||||
y_te = test_ds.targets[:1000].long()
|
||||
|
||||
pca = PCA(n_components=32, whiten=True, random_state=seed)
|
||||
x_tr_pca = pca.fit_transform(x_tr_raw)
|
||||
x_te_pca = pca.transform(x_te_raw)
|
||||
|
||||
x_tr = torch.tensor(x_tr_pca, dtype=torch.float32)
|
||||
x_te = torch.tensor(x_te_pca, dtype=torch.float32)
|
||||
|
||||
model = nn.Linear(32, 10)
|
||||
loss_fn = nn.CrossEntropyLoss()
|
||||
return x_tr, y_tr, (x_te, y_te), model, loss_fn, "multiclass"
|
||||
|
||||
|
||||
DATASET_LOADERS = {
|
||||
"xor": get_xor_workload,
|
||||
"iris": get_iris_workload,
|
||||
"mnist": get_mnist_workload,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PSO Stage Plugin Method Comparison Surface"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-methods",
|
||||
action="store_true",
|
||||
help="List available movement methods and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset",
|
||||
choices=["xor", "iris", "mnist"],
|
||||
default="xor",
|
||||
help="Target dataset workload (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--methods",
|
||||
nargs="+",
|
||||
default=["all"],
|
||||
help="Movement method keys to evaluate or 'all' (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seeds",
|
||||
type=int,
|
||||
nargs="+",
|
||||
default=[42],
|
||||
help="Random seed list (default: 42)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json-path",
|
||||
"--output-json",
|
||||
"--json",
|
||||
dest="json_path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional JSON file path to save detailed evaluation metrics",
|
||||
)
|
||||
|
||||
# Add standard stage selector and hyperparameter options
|
||||
add_pso_args(parser, defaults={"n_particles": 20, "epochs": 30})
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_methods:
|
||||
print_available_methods()
|
||||
sys.exit(0)
|
||||
|
||||
# Determine movement methods to test
|
||||
available_m_plugins = available_plugins("movement")
|
||||
if "all" in args.methods or "ALL" in args.methods:
|
||||
methods_to_test = list(available_m_plugins.keys())
|
||||
else:
|
||||
methods_to_test = []
|
||||
for m in args.methods:
|
||||
if m not in available_m_plugins:
|
||||
raise ValueError(
|
||||
f"Unknown movement method '{m}'. Available: {list(available_m_plugins.keys())}"
|
||||
)
|
||||
methods_to_test.append(m)
|
||||
|
||||
loader = DATASET_LOADERS[args.dataset]
|
||||
eval_stage = args.evaluation
|
||||
fitness_size = args.fitness_size if eval_stage == "fixed_subset" else None
|
||||
|
||||
if eval_stage == "fixed_subset" and fitness_size is None:
|
||||
if args.dataset == "xor":
|
||||
fitness_size = 4
|
||||
elif args.dataset == "iris":
|
||||
fitness_size = 100
|
||||
elif args.dataset == "mnist":
|
||||
fitness_size = 2000
|
||||
refine_stage = args.refinement
|
||||
refinement_epochs = args.refinement_epochs if refine_stage == "adam" else 0
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
parsed_method_opts = parse_method_options(args.method_options)
|
||||
has_val = args.dataset != "xor"
|
||||
|
||||
print("=" * 80)
|
||||
print(f"PSO Movement Method Comparison on '{args.dataset}' Dataset")
|
||||
print(
|
||||
f"Stages: initialization='{args.initialization}', evaluation='{eval_stage}', "
|
||||
f"convergence='{args.convergence}', refinement='{refine_stage}'"
|
||||
)
|
||||
print(
|
||||
f"Parameters: particles={args.n_particles}, epochs={args.epochs}, seeds={args.seeds}"
|
||||
)
|
||||
print("=" * 80)
|
||||
if has_val:
|
||||
print(
|
||||
f"{'Method':<18} {'Seed':<6} {'Val Loss':<12} {'Val Accuracy':<12} {'Val MSE':<12} {'Time (s)':<10}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"{'Method':<18} {'Seed':<6} {'Loss':<12} {'Accuracy':<12} {'MSE':<12} {'Time (s)':<10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
for method_key in methods_to_test:
|
||||
for seed in args.seeds:
|
||||
x_tr, y_tr, val_data, model, loss_fn, task = loader(seed)
|
||||
|
||||
eff_fitness_size = (
|
||||
min(fitness_size, x_tr.shape[0])
|
||||
if fitness_size is not None
|
||||
else None
|
||||
)
|
||||
|
||||
c0 = args.c0
|
||||
c1 = args.c1
|
||||
w_min = args.w_min
|
||||
w_max = args.w_max
|
||||
|
||||
neg_swarm = (
|
||||
args.negative_swarm
|
||||
if method_key not in ("fips", "clpso", "bare_bones")
|
||||
else 0.0
|
||||
)
|
||||
mut_swarm = args.mutation_swarm if method_key != "bare_bones" else 0.0
|
||||
vel_ratio = (
|
||||
args.velocity_limit_ratio if method_key != "bare_bones" else None
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"loss": loss_fn,
|
||||
"task": task,
|
||||
"method": method_key,
|
||||
"initialization": args.initialization,
|
||||
"evaluation": eval_stage,
|
||||
"convergence": args.convergence,
|
||||
"refinement": refine_stage,
|
||||
"method_options": parsed_method_opts,
|
||||
"n_particles": args.n_particles,
|
||||
"c0": c0,
|
||||
"c1": c1,
|
||||
"w_min": w_min,
|
||||
"w_max": w_max,
|
||||
"negative_swarm": neg_swarm,
|
||||
"mutation_swarm": mut_swarm,
|
||||
"particle_min": args.particle_min,
|
||||
"particle_max": args.particle_max,
|
||||
"velocity_limit_ratio": vel_ratio,
|
||||
"boundary_strategy": args.boundary_strategy,
|
||||
"initial_position_noise": args.initial_position_noise,
|
||||
"seed": seed,
|
||||
"device": args.device,
|
||||
"fitness_size": eff_fitness_size,
|
||||
"convergence_patience": args.convergence_patience,
|
||||
"convergence_min_delta": args.convergence_min_delta,
|
||||
"convergence_monitor": args.convergence_monitor,
|
||||
"refinement_epochs": refinement_epochs,
|
||||
"refinement_lr": args.refinement_lr,
|
||||
}
|
||||
|
||||
if method_key == "adaptive_moment":
|
||||
if args.moment_blend is not None:
|
||||
kwargs["moment_blend"] = args.moment_blend
|
||||
elif "moment_blend" in parsed_method_opts:
|
||||
kwargs["moment_blend"] = float(parsed_method_opts["moment_blend"])
|
||||
else:
|
||||
kwargs["moment_blend"] = 0.25
|
||||
|
||||
if args.moment_beta1 is not None:
|
||||
kwargs["moment_beta1"] = args.moment_beta1
|
||||
if args.moment_beta2 is not None:
|
||||
kwargs["moment_beta2"] = args.moment_beta2
|
||||
if args.moment_step_size is not None:
|
||||
kwargs["moment_step_size"] = args.moment_step_size
|
||||
if args.moment_epsilon is not None:
|
||||
kwargs["moment_epsilon"] = args.moment_epsilon
|
||||
|
||||
opt = Optimizer(**kwargs)
|
||||
|
||||
start_t = time.perf_counter()
|
||||
best_score = opt.fit(
|
||||
x_tr,
|
||||
y_tr,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=eff_fitness_size,
|
||||
renewal=args.renewal,
|
||||
validation_data=val_data,
|
||||
output_dir=None,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
elapsed_t = time.perf_counter() - start_t
|
||||
|
||||
tr_loss, tr_acc, tr_mse = best_score
|
||||
if val_data is not None:
|
||||
val_x, val_y = val_data
|
||||
val_score = opt.evaluate(val_x, val_y)
|
||||
eval_loss, eval_acc, eval_mse = val_score
|
||||
score_src = "validation"
|
||||
else:
|
||||
eval_loss, eval_acc, eval_mse = tr_loss, tr_acc, tr_mse
|
||||
score_src = "training"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"method": method_key,
|
||||
"seed": seed,
|
||||
"score_source": score_src,
|
||||
"train_loss": tr_loss,
|
||||
"train_accuracy": tr_acc,
|
||||
"train_mse": tr_mse,
|
||||
"eval_loss": eval_loss,
|
||||
"eval_accuracy": eval_acc,
|
||||
"eval_mse": eval_mse,
|
||||
"loss": eval_loss,
|
||||
"accuracy": eval_acc,
|
||||
"mse": eval_mse,
|
||||
"elapsed_time": elapsed_t,
|
||||
}
|
||||
)
|
||||
|
||||
print(
|
||||
f"{method_key:<18} {seed:<6} {eval_loss:<12.6f} {eval_acc:<12.6f} {eval_mse:<12.6f} {elapsed_t:<10.4f}"
|
||||
)
|
||||
|
||||
print("-" * 80)
|
||||
print("\nAggregate Summary (Mean across seeds):")
|
||||
print("=" * 80)
|
||||
if has_val:
|
||||
print(
|
||||
f"{'Method':<18} {'Mean Val Loss':<14} {'Mean Val Acc':<14} {'Mean Val MSE':<14} {'Mean Time (s)':<12}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"{'Method':<18} {'Mean Loss':<12} {'Mean Acc':<12} {'Mean MSE':<12} {'Mean Time (s)':<12}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
summary_list: list[dict[str, Any]] = []
|
||||
for method_key in methods_to_test:
|
||||
method_runs = [r for r in results if r["method"] == method_key]
|
||||
if not method_runs:
|
||||
continue
|
||||
n_runs = len(method_runs)
|
||||
mean_tr_loss = sum(r["train_loss"] for r in method_runs) / n_runs
|
||||
mean_tr_acc = sum(r["train_accuracy"] for r in method_runs) / n_runs
|
||||
mean_tr_mse = sum(r["train_mse"] for r in method_runs) / n_runs
|
||||
mean_eval_loss = sum(r["eval_loss"] for r in method_runs) / n_runs
|
||||
mean_eval_acc = sum(r["eval_accuracy"] for r in method_runs) / n_runs
|
||||
mean_eval_mse = sum(r["eval_mse"] for r in method_runs) / n_runs
|
||||
mean_time = sum(r["elapsed_time"] for r in method_runs) / n_runs
|
||||
score_src = method_runs[0]["score_source"]
|
||||
|
||||
summary_entry = {
|
||||
"method": method_key,
|
||||
"title": available_m_plugins[method_key].title,
|
||||
"source": available_m_plugins[method_key].source,
|
||||
"score_source": score_src,
|
||||
"mean_train_loss": mean_tr_loss,
|
||||
"mean_train_accuracy": mean_tr_acc,
|
||||
"mean_train_mse": mean_tr_mse,
|
||||
"mean_eval_loss": mean_eval_loss,
|
||||
"mean_eval_accuracy": mean_eval_acc,
|
||||
"mean_eval_mse": mean_eval_mse,
|
||||
"mean_loss": mean_eval_loss,
|
||||
"mean_accuracy": mean_eval_acc,
|
||||
"mean_mse": mean_eval_mse,
|
||||
"mean_elapsed_time": mean_time,
|
||||
"runs": n_runs,
|
||||
}
|
||||
summary_list.append(summary_entry)
|
||||
|
||||
if has_val:
|
||||
print(
|
||||
f"{method_key:<18} {mean_eval_loss:<14.6f} {mean_eval_acc:<14.6f} {mean_eval_mse:<14.6f} {mean_time:<12.4f}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"{method_key:<18} {mean_eval_loss:<12.6f} {mean_eval_acc:<12.6f} {mean_eval_mse:<12.6f} {mean_time:<12.4f}"
|
||||
)
|
||||
print("=" * 80)
|
||||
|
||||
if args.json_path:
|
||||
payload = {
|
||||
"dataset": args.dataset,
|
||||
"score_source": "validation" if has_val else "training",
|
||||
"selectors": {
|
||||
"initialization": args.initialization,
|
||||
"evaluation": eval_stage,
|
||||
"convergence": args.convergence,
|
||||
"refinement": refine_stage,
|
||||
},
|
||||
"parameters": {
|
||||
"n_particles": args.n_particles,
|
||||
"epochs": args.epochs,
|
||||
"batch_size": args.batch_size,
|
||||
"fitness_size": fitness_size,
|
||||
"refinement_epochs": refinement_epochs,
|
||||
"refinement_lr": args.refinement_lr,
|
||||
"seeds": args.seeds,
|
||||
},
|
||||
"results": results,
|
||||
"summary": summary_list,
|
||||
}
|
||||
with open(args.json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
print(f"\nSaved comparison results JSON to: {args.json_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,932 @@
|
||||
"""
|
||||
MNIST Deep Accuracy Study: Architecture vs Optimizer Profiles
|
||||
|
||||
Evaluates official MNIST (60,000 train / 10,000 test) across:
|
||||
1. Architecture Lane: Raw Linear, Raw MLP, Compact CNN under standard full-data Adam.
|
||||
2. Optimizer Lane: Adam-Only, PSO-Only (adaptive_moment on 2k subset), and Hybrid (PSO warm start + Adam fine-tuning) on Compact CNN.
|
||||
|
||||
Contract:
|
||||
- Official 60k train / 10k test split with train-only statistics normalization (no PCA).
|
||||
- Avoid BatchNorm/Dropout so PSO and eval semantics match.
|
||||
- Seed model construction identically per seed to preserve explicit initial state fingerprint across lanes.
|
||||
- Fixed no-scheduler contract for Adam with CrossEntropyLoss and lr=1e-3.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
# Ensure test/ directory is in sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from benchmark_suite import (
|
||||
calc_stats,
|
||||
compute_data_fingerprint,
|
||||
compute_model_fingerprint,
|
||||
extract_plugin_metadata,
|
||||
get_hardware_provenance,
|
||||
resolve_execution_device,
|
||||
save_json_atomic,
|
||||
sync_device,
|
||||
)
|
||||
from pso import Optimizer, __version__ as pso_version
|
||||
|
||||
DEEP_ACCURACY_PROTOCOL_VERSION = "1.0.0"
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Data Preparation (No PCA, Raw 1x28x28)
|
||||
# ==========================================
|
||||
|
||||
def prepare_deep_accuracy_mnist_data() -> Tuple[
|
||||
torch.Tensor,
|
||||
torch.Tensor,
|
||||
torch.Tensor,
|
||||
torch.Tensor,
|
||||
str,
|
||||
Dict[str, Any],
|
||||
]:
|
||||
"""
|
||||
Loads official torchvision MNIST dataset (60,000 train / 10,000 test).
|
||||
Normalizes images using train-only mean and std (no PCA).
|
||||
Validates sample counts and label range [0, 9].
|
||||
Returns (x_train, y_train, x_test, y_test, data_fingerprint, provenance_dict).
|
||||
"""
|
||||
from torchvision.datasets import MNIST
|
||||
|
||||
cache_dir = Path("result/cache")
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
train_dataset = MNIST(root=str(cache_dir), train=True, download=True)
|
||||
test_dataset = MNIST(root=str(cache_dir), train=False, download=True)
|
||||
|
||||
n_train = len(train_dataset.data)
|
||||
n_test = len(test_dataset.data)
|
||||
if n_train != 60000:
|
||||
raise ValueError(f"Expected 60,000 training samples; got {n_train}")
|
||||
if n_test != 10000:
|
||||
raise ValueError(f"Expected 10,000 test samples; got {n_test}")
|
||||
|
||||
y_train = train_dataset.targets.long()
|
||||
y_test = test_dataset.targets.long()
|
||||
|
||||
min_tr, max_tr = int(y_train.min()), int(y_train.max())
|
||||
min_te, max_te = int(y_test.min()), int(y_test.max())
|
||||
if min_tr != 0 or max_tr != 9:
|
||||
raise ValueError(f"Train label range must be [0, 9]; got [{min_tr}, {max_tr}]")
|
||||
if min_te != 0 or max_te != 9:
|
||||
raise ValueError(f"Test label range must be [0, 9]; got [{min_te}, {max_te}]")
|
||||
|
||||
x_train_raw = train_dataset.data.float() / 255.0 # (60000, 28, 28)
|
||||
x_test_raw = test_dataset.data.float() / 255.0 # (10000, 28, 28)
|
||||
|
||||
# Compute normalization statistics from TRAIN split only
|
||||
mean_val = float(x_train_raw.mean())
|
||||
std_val = float(x_train_raw.std())
|
||||
|
||||
x_train_norm = ((x_train_raw - mean_val) / std_val).unsqueeze(1) # (60000, 1, 28, 28)
|
||||
x_test_norm = ((x_test_raw - mean_val) / std_val).unsqueeze(1) # (10000, 1, 28, 28)
|
||||
|
||||
data_fp = compute_data_fingerprint(x_train_norm, x_test_norm, y_train, y_test)
|
||||
normalization_provenance = {
|
||||
"input_shape": [1, 28, 28],
|
||||
"pca": False,
|
||||
"raw_inputs": True,
|
||||
"normalization_scope": "official_train_split_60000_only",
|
||||
"train_mean": round(mean_val, 6),
|
||||
"train_std": round(std_val, 6),
|
||||
"train_samples": 60000,
|
||||
"test_samples": 10000,
|
||||
}
|
||||
return x_train_norm, y_train, x_test_norm, y_test, data_fp, normalization_provenance
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Architectures (No BatchNorm / No Dropout)
|
||||
# ==========================================
|
||||
|
||||
def count_parameters(model: nn.Module) -> int:
|
||||
return sum(p.numel() for p in model.parameters() if p.requires_grad)
|
||||
|
||||
|
||||
def make_raw_linear(seed: int = 41) -> nn.Module:
|
||||
torch.manual_seed(seed)
|
||||
return nn.Sequential(
|
||||
nn.Flatten(),
|
||||
nn.Linear(784, 10),
|
||||
)
|
||||
|
||||
|
||||
def make_raw_mlp(seed: int = 41) -> nn.Module:
|
||||
torch.manual_seed(seed)
|
||||
return nn.Sequential(
|
||||
nn.Flatten(),
|
||||
nn.Linear(784, 128),
|
||||
nn.ReLU(),
|
||||
nn.Linear(128, 64),
|
||||
nn.ReLU(),
|
||||
nn.Linear(64, 10),
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Evaluation & Training Routines
|
||||
# ==========================================
|
||||
|
||||
def evaluate_model_on_test(
|
||||
model: nn.Module,
|
||||
x_test: torch.Tensor,
|
||||
y_test: torch.Tensor,
|
||||
device: torch.device,
|
||||
batch_size: int = 512,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Evaluates model on test data without gradients.
|
||||
Returns (test_loss, test_accuracy).
|
||||
"""
|
||||
model.to(device)
|
||||
model.eval()
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
total_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
|
||||
with torch.no_grad():
|
||||
n_test = x_test.shape[0]
|
||||
for i in range(0, n_test, batch_size):
|
||||
bx = x_test[i : i + batch_size].to(device)
|
||||
by = y_test[i : i + batch_size].to(device)
|
||||
outputs = model(bx)
|
||||
loss = criterion(outputs, by)
|
||||
total_loss += loss.item() * bx.size(0)
|
||||
preds = outputs.argmax(dim=1)
|
||||
correct += (preds == by).sum().item()
|
||||
total += bx.size(0)
|
||||
|
||||
avg_loss = total_loss / total if total > 0 else 0.0
|
||||
accuracy = correct / total if total > 0 else 0.0
|
||||
return float(avg_loss), float(accuracy)
|
||||
|
||||
|
||||
def train_adam_routine(
|
||||
model: nn.Module,
|
||||
x_train: torch.Tensor,
|
||||
y_train: torch.Tensor,
|
||||
x_test: torch.Tensor,
|
||||
y_test: torch.Tensor,
|
||||
epochs: int,
|
||||
batch_size: int,
|
||||
lr: float,
|
||||
seed: int,
|
||||
device: torch.device,
|
||||
) -> Tuple[List[Dict[str, Any]], float, float, float]:
|
||||
"""
|
||||
Standard full-data Adam training routine with CrossEntropyLoss, Adam lr, fixed no-scheduler contract.
|
||||
Returns (history, final_test_loss, final_test_acc, elapsed_sec).
|
||||
"""
|
||||
model.to(device)
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
generator = torch.Generator().manual_seed(seed)
|
||||
n_train = x_train.shape[0]
|
||||
|
||||
history = []
|
||||
init_loss, init_acc = evaluate_model_on_test(model, x_test, y_test, device)
|
||||
history.append({"epoch": 0, "test_loss": round(init_loss, 6), "test_acc": round(init_acc, 6)})
|
||||
|
||||
sync_device(device)
|
||||
t0 = time.time()
|
||||
for epoch in range(1, epochs + 1):
|
||||
model.train()
|
||||
perm = torch.randperm(n_train, generator=generator)
|
||||
for i in range(0, n_train, batch_size):
|
||||
indices = perm[i : i + batch_size]
|
||||
bx = x_train[indices].to(device)
|
||||
by = y_train[indices].to(device)
|
||||
optimizer.zero_grad()
|
||||
outputs = model(bx)
|
||||
loss = criterion(outputs, by)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
test_loss, test_acc = evaluate_model_on_test(model, x_test, y_test, device)
|
||||
history.append({
|
||||
"epoch": epoch,
|
||||
"test_loss": round(test_loss, 6),
|
||||
"test_acc": round(test_acc, 6),
|
||||
})
|
||||
|
||||
sync_device(device)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
final_loss = history[-1]["test_loss"]
|
||||
final_acc = history[-1]["test_acc"]
|
||||
return history, final_loss, final_acc, elapsed
|
||||
|
||||
|
||||
def run_pso_routine(
|
||||
model: nn.Module,
|
||||
x_train: torch.Tensor,
|
||||
y_train: torch.Tensor,
|
||||
x_test: torch.Tensor,
|
||||
y_test: torch.Tensor,
|
||||
pso_epochs: int,
|
||||
n_particles: int,
|
||||
fitness_size: int,
|
||||
seed: int,
|
||||
device: torch.device,
|
||||
) -> Tuple[nn.Module, Dict[str, float], float, float, Dict[str, Any]]:
|
||||
"""
|
||||
PSO-only adaptive_moment on fixed train subset without gradient refinement.
|
||||
Returns (best_model, fitness_score_dict, test_loss, test_acc, pso_metadata).
|
||||
"""
|
||||
model.to(device)
|
||||
loss_fn = nn.CrossEntropyLoss()
|
||||
|
||||
opt = Optimizer(
|
||||
model=model,
|
||||
loss=loss_fn,
|
||||
task="multiclass",
|
||||
method="adaptive_moment",
|
||||
evaluation="fixed_subset",
|
||||
fitness_size=fitness_size,
|
||||
n_particles=n_particles,
|
||||
c0=1.49618,
|
||||
c1=1.49618,
|
||||
w_min=0.7298,
|
||||
w_max=0.7298,
|
||||
particle_min=-3.0,
|
||||
particle_max=3.0,
|
||||
boundary_strategy="reflect",
|
||||
velocity_limit_ratio=0.025,
|
||||
mutation_swarm=0.02,
|
||||
initialization="model_noise",
|
||||
initial_position_noise=0.05,
|
||||
moment_blend=0.06,
|
||||
moment_step_size=0.5,
|
||||
moment_beta1=0.9,
|
||||
seed=seed,
|
||||
device=device,
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
opt.fit(x_train, y_train, epochs=pso_epochs)
|
||||
sync_device(device)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
best_model = opt.get_best_model()
|
||||
best_score_tuple = opt.get_best_score()
|
||||
fitness_score = {
|
||||
"subset_loss": round(float(best_score_tuple[0]), 6),
|
||||
"subset_acc": round(float(best_score_tuple[1]), 6),
|
||||
"subset_mse": round(float(best_score_tuple[2]), 6),
|
||||
}
|
||||
|
||||
test_loss, test_acc = evaluate_model_on_test(best_model, x_test, y_test, device)
|
||||
plugin_meta = extract_plugin_metadata(opt)
|
||||
|
||||
pso_meta = {
|
||||
"elapsed_sec": round(elapsed, 4),
|
||||
"particles": n_particles,
|
||||
"pso_epochs": pso_epochs,
|
||||
"fitness_size": fitness_size,
|
||||
"plugins": plugin_meta,
|
||||
}
|
||||
return best_model, fitness_score, float(test_loss), float(test_acc), pso_meta
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Output Generation (CSV, Plot)
|
||||
# ==========================================
|
||||
|
||||
def save_csv_records(records: List[Dict[str, Any]], csv_path: Path):
|
||||
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = [
|
||||
"lane",
|
||||
"profile_or_arch",
|
||||
"seed",
|
||||
"model_name",
|
||||
"param_count",
|
||||
"initial_test_acc",
|
||||
"final_test_acc",
|
||||
"final_test_loss",
|
||||
"subset_fitness_acc",
|
||||
"subset_fitness_loss",
|
||||
"pso_epochs",
|
||||
"adam_epochs",
|
||||
"elapsed_sec",
|
||||
"model_fingerprint",
|
||||
"data_fingerprint",
|
||||
]
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for r in records:
|
||||
writer.writerow({
|
||||
"lane": r.get("lane"),
|
||||
"profile_or_arch": r.get("profile_or_arch"),
|
||||
"seed": r.get("seed"),
|
||||
"model_name": r.get("model_name"),
|
||||
"param_count": r.get("param_count"),
|
||||
"initial_test_acc": r.get("initial_test_acc"),
|
||||
"final_test_acc": r.get("final_test_acc"),
|
||||
"final_test_loss": r.get("final_test_loss"),
|
||||
"subset_fitness_acc": r.get("subset_fitness_acc"),
|
||||
"subset_fitness_loss": r.get("subset_fitness_loss"),
|
||||
"pso_epochs": r.get("pso_epochs"),
|
||||
"adam_epochs": r.get("adam_epochs"),
|
||||
"elapsed_sec": r.get("elapsed_sec"),
|
||||
"model_fingerprint": r.get("model_fingerprint"),
|
||||
"data_fingerprint": r.get("data_fingerprint"),
|
||||
})
|
||||
|
||||
|
||||
def render_plots(
|
||||
arch_summary: Dict[str, Dict[str, float]],
|
||||
opt_summary: Dict[str, Dict[str, float]],
|
||||
figure_path: Path,
|
||||
):
|
||||
figure_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
|
||||
|
||||
if arch_summary:
|
||||
arch_labels = list(arch_summary.keys())
|
||||
arch_means = [arch_summary[k]["mean"] * 100 for k in arch_labels]
|
||||
arch_sds = [arch_summary[k]["std"] * 100 for k in arch_labels]
|
||||
arch_display = {
|
||||
"raw_linear": "Raw Linear",
|
||||
"raw_mlp": "Raw MLP",
|
||||
"compact_cnn": "Compact CNN",
|
||||
}
|
||||
|
||||
x_arch = np.arange(len(arch_labels))
|
||||
ax1.bar(x_arch, arch_means, yerr=arch_sds, capsize=5, color="#56B4E9", edgecolor="black", alpha=0.85)
|
||||
ax1.axhline(98.0, color="red", linestyle="--", linewidth=1.5, label="98% Target")
|
||||
ax1.set_xticks(x_arch)
|
||||
ax1.set_xticklabels([arch_display[k] for k in arch_labels], rotation=15)
|
||||
ax1.set_ylabel("Final Test Accuracy (%)")
|
||||
ax1.set_title("Architecture Lane (Full-Data Adam)")
|
||||
ax1.set_ylim(0, 110)
|
||||
ax1.set_axisbelow(True)
|
||||
ax1.grid(axis="y", linestyle=":", alpha=0.6)
|
||||
ax1.legend(loc="lower left")
|
||||
|
||||
for i, (m, sd) in enumerate(zip(arch_means, arch_sds)):
|
||||
ax1.text(
|
||||
i,
|
||||
105.0 if m >= 90.0 else min(m + sd + 1.5, 102.5),
|
||||
f"{m:.2f}%",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontsize=9,
|
||||
fontweight="bold",
|
||||
bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.9, "pad": 1.5},
|
||||
)
|
||||
|
||||
if opt_summary:
|
||||
opt_labels = list(opt_summary.keys())
|
||||
opt_means = [opt_summary[k]["mean"] * 100 for k in opt_labels]
|
||||
opt_sds = [opt_summary[k]["std"] * 100 for k in opt_labels]
|
||||
opt_display = {
|
||||
"adam_only": "Adam Only",
|
||||
"pso_only": "PSO Only",
|
||||
"hybrid": "PSO → Adam",
|
||||
}
|
||||
|
||||
x_opt = np.arange(len(opt_labels))
|
||||
colors = ["#009E73", "#E69F00", "#CC79A7"]
|
||||
ax2.bar(x_opt, opt_means, yerr=opt_sds, capsize=5, color=colors[:len(opt_labels)], edgecolor="black", alpha=0.85)
|
||||
ax2.axhline(98.0, color="red", linestyle="--", linewidth=1.5, label="98% Target")
|
||||
ax2.set_xticks(x_opt)
|
||||
ax2.set_xticklabels([opt_display[k] for k in opt_labels], rotation=15)
|
||||
ax2.set_ylabel("Final Test Accuracy (%)")
|
||||
ax2.set_title("Optimizer Lane (Compact CNN)")
|
||||
ax2.set_ylim(0, 110)
|
||||
ax2.set_axisbelow(True)
|
||||
ax2.grid(axis="y", linestyle=":", alpha=0.6)
|
||||
ax2.legend(loc="lower left")
|
||||
|
||||
for i, (m, sd) in enumerate(zip(opt_means, opt_sds)):
|
||||
ax2.text(
|
||||
i,
|
||||
105.0 if m >= 90.0 else min(m + sd + 1.5, 102.5),
|
||||
f"{m:.2f}%",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontsize=9,
|
||||
fontweight="bold",
|
||||
bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.9, "pad": 1.5},
|
||||
)
|
||||
|
||||
plt.suptitle("MNIST Deep Accuracy Study: Architectures & Optimizer Profiles", fontsize=14, fontweight="bold")
|
||||
plt.tight_layout()
|
||||
fig.savefig(figure_path, dpi=300)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Main CLI & Runner
|
||||
# ==========================================
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MNIST Deep Accuracy Study: Architecture vs Optimizer Profiles"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seeds",
|
||||
type=str,
|
||||
default="101,102,103",
|
||||
help="Comma-separated random seeds (default: 101,102,103)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--adam-epochs",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Full-data Adam training epochs (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pso-epochs",
|
||||
type=int,
|
||||
default=40,
|
||||
help="PSO swarm optimization epochs (default: 40)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--particles",
|
||||
"--n-particles",
|
||||
type=int,
|
||||
default=30,
|
||||
dest="particles",
|
||||
help="Number of PSO particles (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fitness-size",
|
||||
type=int,
|
||||
default=2000,
|
||||
help="Fixed train subset fitness size for PSO (default: 2000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=256,
|
||||
help="Batch size for Adam DataLoader (default: 256)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lr",
|
||||
"--learning-rate",
|
||||
type=float,
|
||||
default=1e-3,
|
||||
dest="lr",
|
||||
help="Learning rate for Adam optimizer (default: 1e-3)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Target PyTorch device ('cpu', 'cuda', 'mps'; default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json-path",
|
||||
type=str,
|
||||
default="benchmark_results/pso_v4_deep_accuracy.json",
|
||||
help="JSON result output path (default: benchmark_results/pso_v4_deep_accuracy.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv-path",
|
||||
type=str,
|
||||
default="benchmark_results/pso_v4_deep_accuracy.csv",
|
||||
help="CSV result output path (default: benchmark_results/pso_v4_deep_accuracy.csv)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--figure-path",
|
||||
"--plot-path",
|
||||
type=str,
|
||||
default="history_plt/pso_v4_deep_accuracy.png",
|
||||
dest="figure_path",
|
||||
help="Figure output path (default: history_plt/pso_v4_deep_accuracy.png)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lanes",
|
||||
"--profiles",
|
||||
type=str,
|
||||
choices=["all", "architectures", "optimizers"],
|
||||
default="all",
|
||||
help="Lanes/profiles to evaluate (choices: all, architectures, optimizers; default: all)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
seed_list = [int(s.strip()) for s in args.seeds.split(",") if s.strip()]
|
||||
if not seed_list:
|
||||
raise ValueError("--seeds must contain at least one integer")
|
||||
if len(seed_list) != len(set(seed_list)):
|
||||
raise ValueError("--seeds must not contain duplicates")
|
||||
if any(seed < 0 for seed in seed_list):
|
||||
raise ValueError("--seeds values must be non-negative")
|
||||
for name, value in (
|
||||
("--adam-epochs", args.adam_epochs),
|
||||
("--pso-epochs", args.pso_epochs),
|
||||
("--particles", args.particles),
|
||||
("--fitness-size", args.fitness_size),
|
||||
("--batch-size", args.batch_size),
|
||||
):
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
if not math.isfinite(args.lr) or args.lr <= 0.0:
|
||||
raise ValueError("--lr must be a positive finite number")
|
||||
|
||||
device = resolve_execution_device(args.device)
|
||||
|
||||
json_path = Path(args.json_path)
|
||||
csv_path = Path(args.csv_path)
|
||||
figure_path = Path(args.figure_path)
|
||||
|
||||
print(f"=== Starting MNIST Deep Accuracy Study (Protocol v{DEEP_ACCURACY_PROTOCOL_VERSION}) ===")
|
||||
print(f"Device: {device} | Seeds: {seed_list} | Adam Epochs: {args.adam_epochs} | PSO Epochs: {args.pso_epochs}")
|
||||
print(f"Particles: {args.particles} | Fitness Size: {args.fitness_size} | Batch Size: {args.batch_size} | LR: {args.lr}")
|
||||
|
||||
# Prepare data
|
||||
x_train, y_train, x_test, y_test, data_fp, norm_provenance = prepare_deep_accuracy_mnist_data()
|
||||
print(f"Data Loaded: Train {x_train.shape[0]} / Test {x_test.shape[0]} | Fingerprint: {data_fp[:12]}...")
|
||||
|
||||
hardware_prov = get_hardware_provenance(device)
|
||||
all_csv_records: List[Dict[str, Any]] = []
|
||||
architecture_lane_runs: List[Dict[str, Any]] = []
|
||||
optimizer_lane_runs: List[Dict[str, Any]] = []
|
||||
|
||||
# Map architecture factories
|
||||
arch_factories = {
|
||||
"raw_linear": ("Raw Linear (784->10)", make_raw_linear),
|
||||
"raw_mlp": ("Raw MLP (784->128->64->10)", make_raw_mlp),
|
||||
"compact_cnn": ("Compact CNN (9,098 params)", make_compact_cnn),
|
||||
}
|
||||
|
||||
try:
|
||||
# ----------------------------------------------------
|
||||
# 1. Architecture Lane
|
||||
# ----------------------------------------------------
|
||||
compact_cnn_adam_cache: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
if args.lanes in ("all", "architectures"):
|
||||
print("\n--- Running Architecture Lane (Full-Data Adam) ---")
|
||||
for arch_key, (arch_name, factory) in arch_factories.items():
|
||||
for s in seed_list:
|
||||
# Construct base model and record initial fingerprint
|
||||
model = factory(seed=s)
|
||||
p_count = count_parameters(model)
|
||||
init_fp = compute_model_fingerprint(model)
|
||||
|
||||
init_loss, init_acc = evaluate_model_on_test(model, x_test, y_test, device)
|
||||
|
||||
print(f"[Arch: {arch_key} | Seed: {s}] Params: {p_count} | Init Acc: {init_acc*100:.2f}% | Training Adam...")
|
||||
|
||||
history, final_loss, final_acc, elapsed = train_adam_routine(
|
||||
model=model,
|
||||
x_train=x_train,
|
||||
y_train=y_train,
|
||||
x_test=x_test,
|
||||
y_test=y_test,
|
||||
epochs=args.adam_epochs,
|
||||
batch_size=args.batch_size,
|
||||
lr=args.lr,
|
||||
seed=s,
|
||||
device=device,
|
||||
)
|
||||
|
||||
run_record = {
|
||||
"lane": "architecture",
|
||||
"profile_or_arch": arch_key,
|
||||
"seed": s,
|
||||
"model_name": arch_name,
|
||||
"param_count": p_count,
|
||||
"initial_test_acc": round(init_acc, 6),
|
||||
"final_test_acc": round(final_acc, 6),
|
||||
"final_test_loss": round(final_loss, 6),
|
||||
"subset_fitness_acc": None,
|
||||
"subset_fitness_loss": None,
|
||||
"pso_epochs": 0,
|
||||
"adam_epochs": args.adam_epochs,
|
||||
"elapsed_sec": round(elapsed, 4),
|
||||
"model_fingerprint": init_fp,
|
||||
"data_fingerprint": data_fp,
|
||||
"epoch_history": history,
|
||||
}
|
||||
architecture_lane_runs.append(run_record)
|
||||
all_csv_records.append(run_record)
|
||||
|
||||
if arch_key == "compact_cnn":
|
||||
compact_cnn_adam_cache[s] = run_record
|
||||
|
||||
print(f" -> Final Test Acc: {final_acc*100:.2f}% | Loss: {final_loss:.4f} | Time: {elapsed:.2f}s")
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. Optimizer Lane (Compact CNN)
|
||||
# ----------------------------------------------------
|
||||
if args.lanes in ("all", "optimizers"):
|
||||
print("\n--- Running Optimizer Lane (Compact CNN) ---")
|
||||
profiles = ["adam_only", "pso_only", "hybrid"]
|
||||
|
||||
for prof in profiles:
|
||||
for s in seed_list:
|
||||
# Construct Compact CNN with seed s to ensure same base initial state
|
||||
model_base = make_compact_cnn(seed=s)
|
||||
p_count = count_parameters(model_base)
|
||||
init_fp = compute_model_fingerprint(model_base)
|
||||
init_loss, init_acc = evaluate_model_on_test(model_base, x_test, y_test, device)
|
||||
|
||||
if prof == "adam_only":
|
||||
if s in compact_cnn_adam_cache:
|
||||
# Explicitly reuse record from Architecture Lane
|
||||
cached = compact_cnn_adam_cache[s]
|
||||
run_record = {
|
||||
"lane": "optimizer",
|
||||
"profile_or_arch": "adam_only",
|
||||
"seed": s,
|
||||
"model_name": "Compact CNN (Adam-Only)",
|
||||
"param_count": p_count,
|
||||
"initial_test_acc": cached["initial_test_acc"],
|
||||
"final_test_acc": cached["final_test_acc"],
|
||||
"final_test_loss": cached["final_test_loss"],
|
||||
"subset_fitness_acc": None,
|
||||
"subset_fitness_loss": None,
|
||||
"pso_epochs": 0,
|
||||
"adam_epochs": args.adam_epochs,
|
||||
"elapsed_sec": cached["elapsed_sec"],
|
||||
"model_fingerprint": init_fp,
|
||||
"data_fingerprint": data_fp,
|
||||
"reused_from_architecture_lane": True,
|
||||
"epoch_history": cached["epoch_history"],
|
||||
}
|
||||
print(f"[Opt: adam_only | Seed: {s}] Reused from Architecture Lane | Final Acc: {cached['final_test_acc']*100:.2f}%")
|
||||
else:
|
||||
print(f"[Opt: adam_only | Seed: {s}] Training Adam...")
|
||||
history, final_loss, final_acc, elapsed = train_adam_routine(
|
||||
model=model_base,
|
||||
x_train=x_train,
|
||||
y_train=y_train,
|
||||
x_test=x_test,
|
||||
y_test=y_test,
|
||||
epochs=args.adam_epochs,
|
||||
batch_size=args.batch_size,
|
||||
lr=args.lr,
|
||||
seed=s,
|
||||
device=device,
|
||||
)
|
||||
run_record = {
|
||||
"lane": "optimizer",
|
||||
"profile_or_arch": "adam_only",
|
||||
"seed": s,
|
||||
"model_name": "Compact CNN (Adam-Only)",
|
||||
"param_count": p_count,
|
||||
"initial_test_acc": round(init_acc, 6),
|
||||
"final_test_acc": round(final_acc, 6),
|
||||
"final_test_loss": round(final_loss, 6),
|
||||
"subset_fitness_acc": None,
|
||||
"subset_fitness_loss": None,
|
||||
"pso_epochs": 0,
|
||||
"adam_epochs": args.adam_epochs,
|
||||
"elapsed_sec": round(elapsed, 4),
|
||||
"model_fingerprint": init_fp,
|
||||
"data_fingerprint": data_fp,
|
||||
"reused_from_architecture_lane": False,
|
||||
"epoch_history": history,
|
||||
}
|
||||
print(f" -> Final Test Acc: {final_acc*100:.2f}% | Loss: {final_loss:.4f} | Time: {elapsed:.2f}s")
|
||||
|
||||
optimizer_lane_runs.append(run_record)
|
||||
all_csv_records.append(run_record)
|
||||
|
||||
elif prof == "pso_only":
|
||||
print(
|
||||
f"[Opt: pso_only | Seed: {s}] Running PSO Adaptive Moment "
|
||||
f"on {args.fitness_size:,} fixed-subset samples..."
|
||||
)
|
||||
best_model, fitness_score, pso_test_loss, pso_test_acc, pso_meta = run_pso_routine(
|
||||
model=model_base,
|
||||
x_train=x_train,
|
||||
y_train=y_train,
|
||||
x_test=x_test,
|
||||
y_test=y_test,
|
||||
pso_epochs=args.pso_epochs,
|
||||
n_particles=args.particles,
|
||||
fitness_size=args.fitness_size,
|
||||
seed=s,
|
||||
device=device,
|
||||
)
|
||||
|
||||
run_record = {
|
||||
"lane": "optimizer",
|
||||
"profile_or_arch": "pso_only",
|
||||
"seed": s,
|
||||
"model_name": "Compact CNN (PSO-Only)",
|
||||
"param_count": p_count,
|
||||
"initial_test_acc": round(init_acc, 6),
|
||||
"final_test_acc": round(pso_test_acc, 6),
|
||||
"final_test_loss": round(pso_test_loss, 6),
|
||||
"subset_fitness_acc": fitness_score["subset_acc"],
|
||||
"subset_fitness_loss": fitness_score["subset_loss"],
|
||||
"pso_epochs": args.pso_epochs,
|
||||
"adam_epochs": 0,
|
||||
"elapsed_sec": pso_meta["elapsed_sec"],
|
||||
"model_fingerprint": init_fp,
|
||||
"data_fingerprint": data_fp,
|
||||
"pso_metadata": pso_meta,
|
||||
}
|
||||
optimizer_lane_runs.append(run_record)
|
||||
all_csv_records.append(run_record)
|
||||
|
||||
print(f" -> Fitness Subset Acc: {fitness_score['subset_acc']*100:.2f}% | Full Test Acc: {pso_test_acc*100:.2f}% | Time: {pso_meta['elapsed_sec']:.2f}s")
|
||||
|
||||
elif prof == "hybrid":
|
||||
print(f"[Opt: hybrid | Seed: {s}] Running Hybrid (PSO Warm Start + Adam Fine-Tuning)...")
|
||||
# 1. PSO Warm Start
|
||||
t_hyb_start = time.time()
|
||||
pso_best_model, fitness_score, pso_test_loss, pso_test_acc, pso_meta = run_pso_routine(
|
||||
model=model_base,
|
||||
x_train=x_train,
|
||||
y_train=y_train,
|
||||
x_test=x_test,
|
||||
y_test=y_test,
|
||||
pso_epochs=args.pso_epochs,
|
||||
n_particles=args.particles,
|
||||
fitness_size=args.fitness_size,
|
||||
seed=s,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# 2. Continue with full-data Adam
|
||||
post_adam_history, final_test_loss, final_test_acc, adam_elapsed = train_adam_routine(
|
||||
model=pso_best_model,
|
||||
x_train=x_train,
|
||||
y_train=y_train,
|
||||
x_test=x_test,
|
||||
y_test=y_test,
|
||||
epochs=args.adam_epochs,
|
||||
batch_size=args.batch_size,
|
||||
lr=args.lr,
|
||||
seed=s,
|
||||
device=device,
|
||||
)
|
||||
hyb_total_elapsed = time.time() - t_hyb_start
|
||||
|
||||
run_record = {
|
||||
"lane": "optimizer",
|
||||
"profile_or_arch": "hybrid",
|
||||
"seed": s,
|
||||
"model_name": "Compact CNN (Hybrid)",
|
||||
"param_count": p_count,
|
||||
"initial_test_acc": round(init_acc, 6),
|
||||
"final_test_acc": round(final_test_acc, 6),
|
||||
"final_test_loss": round(final_test_loss, 6),
|
||||
"subset_fitness_acc": fitness_score["subset_acc"],
|
||||
"subset_fitness_loss": fitness_score["subset_loss"],
|
||||
"pso_epochs": args.pso_epochs,
|
||||
"adam_epochs": args.adam_epochs,
|
||||
"elapsed_sec": round(hyb_total_elapsed, 4),
|
||||
"model_fingerprint": init_fp,
|
||||
"data_fingerprint": data_fp,
|
||||
"post_pso_test_acc": round(pso_test_acc, 6),
|
||||
"post_pso_test_loss": round(pso_test_loss, 6),
|
||||
"post_adam_history": post_adam_history,
|
||||
"efficiency_label": "HYBRID_GETS_EXTRA_WORK_UNFAIR_EFFICIENCY_COMPARISON",
|
||||
}
|
||||
optimizer_lane_runs.append(run_record)
|
||||
all_csv_records.append(run_record)
|
||||
|
||||
print(f" -> Post-PSO Test Acc: {pso_test_acc*100:.2f}% | Final Hybrid Test Acc: {final_test_acc*100:.2f}% | Time: {hyb_total_elapsed:.2f}s")
|
||||
|
||||
# Compute summary statistics
|
||||
arch_summary: Dict[str, Dict[str, float]] = {}
|
||||
for arch_key in arch_factories.keys():
|
||||
vals = [r["final_test_acc"] for r in architecture_lane_runs if r["profile_or_arch"] == arch_key]
|
||||
if vals:
|
||||
arch_summary[arch_key] = calc_stats(vals)
|
||||
|
||||
opt_summary: Dict[str, Dict[str, float]] = {}
|
||||
for prof in ["adam_only", "pso_only", "hybrid"]:
|
||||
vals = [r["final_test_acc"] for r in optimizer_lane_runs if r["profile_or_arch"] == prof]
|
||||
if vals:
|
||||
opt_summary[prof] = calc_stats(vals)
|
||||
|
||||
# Structure complete JSON output
|
||||
result_payload = {
|
||||
"protocol_version": DEEP_ACCURACY_PROTOCOL_VERSION,
|
||||
"pso_version": pso_version,
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"completed": True,
|
||||
"error": None,
|
||||
"hardware_provenance": hardware_prov,
|
||||
"data_provenance": norm_provenance,
|
||||
"data_fingerprint": data_fp,
|
||||
"configuration": {
|
||||
"seeds": seed_list,
|
||||
"adam_epochs": args.adam_epochs,
|
||||
"pso_epochs": args.pso_epochs,
|
||||
"particles": args.particles,
|
||||
"fitness_size": args.fitness_size,
|
||||
"batch_size": args.batch_size,
|
||||
"lr": args.lr,
|
||||
"device": str(device),
|
||||
},
|
||||
"summaries": {
|
||||
"architecture_lane": arch_summary,
|
||||
"optimizer_lane": opt_summary,
|
||||
},
|
||||
"architecture_lane_runs": architecture_lane_runs,
|
||||
"optimizer_lane_runs": optimizer_lane_runs,
|
||||
}
|
||||
|
||||
# Save JSON output atomically
|
||||
save_json_atomic(result_payload, json_path)
|
||||
print(f"\nSaved JSON results to {json_path}")
|
||||
|
||||
# Save CSV records and render plot only after all runs succeed
|
||||
save_csv_records(all_csv_records, csv_path)
|
||||
print(f"Saved CSV records to {csv_path}")
|
||||
|
||||
render_plots(arch_summary, opt_summary, figure_path)
|
||||
print(f"Saved summary figure to {figure_path}")
|
||||
|
||||
# Print final summary table
|
||||
print("\n========================================================")
|
||||
print(" FINAL SUMMARY TABLE ")
|
||||
print("========================================================")
|
||||
if arch_summary:
|
||||
print("Architecture Lane (Full-Data Adam):")
|
||||
for arch_key, stats in arch_summary.items():
|
||||
print(f" - {arch_key:15s}: Mean Acc = {stats['mean']*100:6.2f}% ± {stats['std']*100:5.2f}% (Median: {stats['median']*100:.2f}%)")
|
||||
if opt_summary:
|
||||
print("\nOptimizer Lane (Compact CNN):")
|
||||
for prof, stats in opt_summary.items():
|
||||
print(f" - {prof:15s}: Mean Acc = {stats['mean']*100:6.2f}% ± {stats['std']*100:5.2f}% (Median: {stats['median']*100:.2f}%)")
|
||||
print("========================================================\n")
|
||||
|
||||
except Exception as e:
|
||||
error_payload = {
|
||||
"protocol_version": DEEP_ACCURACY_PROTOCOL_VERSION,
|
||||
"pso_version": pso_version,
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"completed": False,
|
||||
"error": str(e),
|
||||
"hardware_provenance": hardware_prov,
|
||||
"configuration": {
|
||||
"seeds": seed_list,
|
||||
"adam_epochs": args.adam_epochs,
|
||||
"pso_epochs": args.pso_epochs,
|
||||
"particles": args.particles,
|
||||
"fitness_size": args.fitness_size,
|
||||
"batch_size": args.batch_size,
|
||||
"lr": args.lr,
|
||||
"device": str(device),
|
||||
},
|
||||
"architecture_lane_runs": architecture_lane_runs,
|
||||
"optimizer_lane_runs": optimizer_lane_runs,
|
||||
}
|
||||
save_json_atomic(error_payload, json_path)
|
||||
print(f"\n[ERROR] Study failed: {e}")
|
||||
print(f"Saved failure audit record to {json_path}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
+1464
File diff suppressed because it is too large
Load Diff
+94
-54
@@ -1,71 +1,111 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from keras.layers import Dense
|
||||
from keras.models import Sequential
|
||||
from keras.utils import to_categorical
|
||||
import argparse
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from sklearn.datasets import load_digits
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from pso import optimizer
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
from pso import Optimizer
|
||||
from cli import add_pso_args, build_optimizer_kwargs
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(Dense(12, input_dim=64, activation="relu"))
|
||||
model.add(Dense(10, activation="relu"))
|
||||
model.add(Dense(10, activation="softmax"))
|
||||
|
||||
return model
|
||||
def make_model(seed: int = 42):
|
||||
torch.manual_seed(seed)
|
||||
return nn.Sequential(
|
||||
nn.Linear(64, 12),
|
||||
nn.ReLU(),
|
||||
nn.Linear(12, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 10),
|
||||
)
|
||||
|
||||
|
||||
def get_data():
|
||||
def get_data(seed: int = 42):
|
||||
digits = load_digits()
|
||||
X = digits.data
|
||||
y = digits.target
|
||||
|
||||
x = X.astype("float32")
|
||||
|
||||
y_class = to_categorical(y)
|
||||
x = digits.data.astype("float32")
|
||||
y = digits.target.astype("int64")
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y_class, test_size=0.2, random_state=42, shuffle=True
|
||||
x, y, test_size=0.2, random_state=seed, shuffle=True
|
||||
)
|
||||
scaler = StandardScaler()
|
||||
x_train = scaler.fit_transform(x_train)
|
||||
x_test = scaler.transform(x_test)
|
||||
|
||||
return (
|
||||
torch.tensor(x_train, dtype=torch.float32),
|
||||
torch.tensor(x_test, dtype=torch.float32),
|
||||
torch.tensor(y_train, dtype=torch.int64),
|
||||
torch.tensor(y_test, dtype=torch.int64),
|
||||
)
|
||||
return x_train, x_test, y_train, y_test
|
||||
|
||||
|
||||
x_train, x_test, y_train, y_test = get_data()
|
||||
model = make_model()
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PSO Digits Benchmark Script")
|
||||
add_pso_args(
|
||||
parser,
|
||||
defaults={
|
||||
"method": "original",
|
||||
"initialization": "model_noise",
|
||||
"evaluation": "fixed_subset",
|
||||
"convergence": "particle_reset",
|
||||
"refinement": "adam",
|
||||
"n_particles": 30,
|
||||
"c0": None,
|
||||
"c1": None,
|
||||
"w_min": None,
|
||||
"w_max": None,
|
||||
"negative_swarm": 0.0,
|
||||
"mutation_swarm": 0.1,
|
||||
"particle_min": -3.0,
|
||||
"particle_max": 3.0,
|
||||
"velocity_limit_ratio": 0.1,
|
||||
"boundary_strategy": "reflect",
|
||||
"seed": 42,
|
||||
"epochs": 80,
|
||||
"batch_size": 200,
|
||||
"fitness_size": 1000,
|
||||
"renewal": "loss",
|
||||
"output_dir": "output/digits",
|
||||
"refinement_epochs": 10,
|
||||
"refinement_lr": 0.001,
|
||||
},
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
digits_pso = optimizer(
|
||||
model,
|
||||
loss="categorical_crossentropy",
|
||||
n_particles=300,
|
||||
c0=0.5,
|
||||
c1=0.3,
|
||||
w_min=0.2,
|
||||
w_max=0.9,
|
||||
negative_swarm=0,
|
||||
mutation_swarm=0.1,
|
||||
convergence_reset=True,
|
||||
convergence_reset_patience=10,
|
||||
convergence_reset_monitor="loss",
|
||||
convergence_reset_min_delta=0.001,
|
||||
)
|
||||
x_train, x_test, y_train, y_test = get_data(seed=args.seed)
|
||||
model = make_model(seed=args.seed)
|
||||
|
||||
digits_pso.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=500,
|
||||
validate_data=(x_test, y_test),
|
||||
log=2,
|
||||
save_info=True,
|
||||
renewal="loss",
|
||||
log_name="digits",
|
||||
)
|
||||
fitness_size = args.fitness_size if args.evaluation == "fixed_subset" else None
|
||||
refinement_epochs = args.refinement_epochs if args.refinement == "adam" else 0
|
||||
|
||||
print("Done!")
|
||||
kwargs = build_optimizer_kwargs(
|
||||
args,
|
||||
model=model,
|
||||
loss=nn.CrossEntropyLoss(),
|
||||
task="multiclass",
|
||||
inertia_profile={"c0": 0.5, "c1": 0.3, "w_min": 0.2, "w_max": 0.9},
|
||||
)
|
||||
digits_pso = Optimizer(**kwargs)
|
||||
|
||||
sys.exit(0)
|
||||
print(f"Optimizer device: {digits_pso.device}")
|
||||
|
||||
best_score = digits_pso.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=fitness_size,
|
||||
renewal=args.renewal,
|
||||
validation_data=(x_test, y_test),
|
||||
output_dir=args.output_dir,
|
||||
save_info=True,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
|
||||
print(f"Done! Best score: {best_score}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
import tensorflow as tf
|
||||
from sklearn.datasets import load_digits
|
||||
from sklearn.model_selection import train_test_split
|
||||
from tensorflow import keras
|
||||
from tensorflow.keras import layers
|
||||
from tensorflow.keras.layers import Dense
|
||||
from tensorflow.keras.models import Sequential
|
||||
from tensorflow.keras.utils import to_categorical
|
||||
|
||||
|
||||
gpus = tf.config.experimental.list_physical_devices("GPU")
|
||||
if gpus:
|
||||
try:
|
||||
tf.config.experimental.set_memory_growth(gpus[0], True)
|
||||
except RuntimeError as r:
|
||||
print(r)
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(Dense(12, input_dim=64, activation="relu"))
|
||||
model.add(Dense(12, activation="relu"))
|
||||
model.add(Dense(10, activation="softmax"))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def get_data():
|
||||
digits = load_digits()
|
||||
X = digits.data
|
||||
y = digits.target
|
||||
|
||||
x = X.astype("float32")
|
||||
|
||||
y_class = to_categorical(y)
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y_class, test_size=0.2, random_state=42, shuffle=True
|
||||
)
|
||||
return x_train, x_test, y_train, y_test
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
model = make_model()
|
||||
x_train, x_test, y_train, y_test = get_data()
|
||||
|
||||
callbacks = [
|
||||
tf.keras.callbacks.EarlyStopping(
|
||||
monitor="val_loss", patience=10, restore_best_weights=True
|
||||
)
|
||||
]
|
||||
|
||||
print(x_train.shape, y_train.shape)
|
||||
|
||||
model.compile(
|
||||
optimizer="adam",
|
||||
loss="categorical_crossentropy",
|
||||
metrics=["accuracy", "mse"],
|
||||
)
|
||||
|
||||
print(model.summary())
|
||||
|
||||
history = model.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=500,
|
||||
batch_size=32,
|
||||
verbose=1,
|
||||
validation_data=(x_test, y_test),
|
||||
callbacks=callbacks,
|
||||
)
|
||||
|
||||
print("Done!")
|
||||
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Digits dataset gradient baseline (PyTorch standard backprop optimizer, non-PSO)."""
|
||||
|
||||
import copy
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
from sklearn.datasets import load_digits
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
|
||||
class DigitsModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(
|
||||
nn.Linear(64, 12),
|
||||
nn.ReLU(),
|
||||
nn.Linear(12, 12),
|
||||
nn.ReLU(),
|
||||
nn.Linear(12, 10),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net(x)
|
||||
|
||||
|
||||
def get_data(seed: int = 42):
|
||||
digits = load_digits()
|
||||
X = digits.data.astype("float32")
|
||||
y = digits.target.astype("int64")
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=0.2, random_state=seed, shuffle=True
|
||||
)
|
||||
return (
|
||||
torch.tensor(x_train, dtype=torch.float32),
|
||||
torch.tensor(x_test, dtype=torch.float32),
|
||||
torch.tensor(y_train, dtype=torch.int64),
|
||||
torch.tensor(y_test, dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
def get_device() -> torch.device:
|
||||
if (
|
||||
hasattr(torch.backends, "mps")
|
||||
and torch.backends.mps.is_built()
|
||||
and torch.backends.mps.is_available()
|
||||
):
|
||||
return torch.device("mps")
|
||||
elif torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def main():
|
||||
torch.manual_seed(42)
|
||||
np.random.seed(42)
|
||||
|
||||
device = get_device()
|
||||
print(f"Selected device: {device}")
|
||||
|
||||
x_train, x_test, y_train, y_test = get_data(seed=42)
|
||||
train_loader = DataLoader(
|
||||
TensorDataset(x_train, y_train), batch_size=32, shuffle=True
|
||||
)
|
||||
val_loader = DataLoader(
|
||||
TensorDataset(x_test, y_test), batch_size=32, shuffle=False
|
||||
)
|
||||
|
||||
model = DigitsModel().to(device)
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
best_val_loss = float("inf")
|
||||
best_state = None
|
||||
patience = 10
|
||||
patience_counter = 0
|
||||
|
||||
for epoch in range(500):
|
||||
model.train()
|
||||
for bx, by in train_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
optimizer.zero_grad()
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
model.eval()
|
||||
val_loss = 0.0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in val_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
val_loss += loss.item() * bx.size(0)
|
||||
total += bx.size(0)
|
||||
|
||||
val_loss /= total
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
best_state = copy.deepcopy(model.state_dict())
|
||||
patience_counter = 0
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= patience:
|
||||
break
|
||||
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
test_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in val_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
test_loss += loss.item() * bx.size(0)
|
||||
preds = out.argmax(dim=1)
|
||||
correct += (preds == by).sum().item()
|
||||
total += bx.size(0)
|
||||
|
||||
test_loss /= total
|
||||
test_acc = correct / total
|
||||
print(f"Final test loss: {test_loss:.4f}, accuracy: {test_acc:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,673 @@
|
||||
"""
|
||||
Adaptive Moment 120-Particle x 240-Epoch MNIST Convergence & Trajectory Analysis
|
||||
|
||||
Evaluates whether the published 120-particle x 80-epoch MNIST PCA32 Adaptive Moment run
|
||||
benefits from continued optimization up to 240 epochs or enters a generalization plateau.
|
||||
|
||||
Predeclared Contract Criteria:
|
||||
1. Exact Replay Verification at Epoch 80 (seeds 71-75):
|
||||
Per-seed test accuracy absolute delta vs baseline <= 0.005 (0.5%p).
|
||||
2. Primary Diagnostic Endpoints: Epochs 80, 120, 160, 200, 240.
|
||||
3. Classifications:
|
||||
- Training Still Improving: Mean global-best fitness loss falls >= 1% from epoch 80 to 240.
|
||||
- Meaningful Held-Out Gain: Mean test accuracy at epoch 240 rises >= 1 percentage point vs epoch 80.
|
||||
- Overfitting Signal: Training loss improves but epoch 240 test accuracy falls >= 1 point.
|
||||
- Early Stagnation: Training loss improves < 1% and absolute test change remains < 1 point.
|
||||
- Generalization Plateau: Training loss improves >= 1% while absolute test gain remains < 1 point.
|
||||
- Late Plateau Diagnostic: 200->240 training-loss improvement < 1% AND absolute test-accuracy change < 0.5 points.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
# Ensure test/ directory is in sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from benchmark_suite import (
|
||||
calc_stats,
|
||||
compute_model_fingerprint,
|
||||
get_hardware_provenance,
|
||||
make_mnist_model,
|
||||
resolve_execution_device,
|
||||
save_json_atomic,
|
||||
sync_device,
|
||||
)
|
||||
from pso import Optimizer, __version__ as pso_version
|
||||
from reproduce_scaling import (
|
||||
REPLAY_SEEDS,
|
||||
REPLAY_TOLERANCE,
|
||||
prepare_full_pca_data,
|
||||
validate_and_load_baseline,
|
||||
)
|
||||
from tuning_suite import TUNING_PROTOCOL_VERSION
|
||||
|
||||
EPOCH_CONVERGENCE_PROTOCOL_VERSION = "1.0.0"
|
||||
CHECKPOINT_EPOCHS = [20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 220, 240]
|
||||
POST80_LOSS_REDUCTION_THRESHOLD = 0.01
|
||||
TEST_ACCURACY_GAIN_THRESHOLD = 0.01
|
||||
OVERFITTING_ACCURACY_DROP_THRESHOLD = -0.01
|
||||
LATE_LOSS_REDUCTION_THRESHOLD = 0.01
|
||||
LATE_ACCURACY_CHANGE_THRESHOLD = 0.005
|
||||
|
||||
|
||||
def run_epoch_convergence_analysis(
|
||||
baseline_path: Path,
|
||||
output_json_path: Path,
|
||||
output_csv_path: Path,
|
||||
figure_path: Path,
|
||||
device_str: str | None = None,
|
||||
) -> bool:
|
||||
device = resolve_execution_device(device_str)
|
||||
hw_provenance = get_hardware_provenance(device)
|
||||
|
||||
# 1. Validate and load baseline
|
||||
baseline_data, baseline_records, winner_cfg, expected_fp = validate_and_load_baseline(
|
||||
baseline_path
|
||||
)
|
||||
if baseline_data.get("device") != device.type:
|
||||
raise ValueError(
|
||||
f"Exact trajectory extension requires baseline device "
|
||||
f"{baseline_data.get('device')!r}; got {device.type!r}."
|
||||
)
|
||||
if baseline_data.get("pso_version") != pso_version:
|
||||
raise ValueError(
|
||||
f"Exact trajectory extension requires pso version "
|
||||
f"{baseline_data.get('pso_version')!r}; got {pso_version!r}."
|
||||
)
|
||||
if baseline_data.get("torch_version") != torch.__version__:
|
||||
raise ValueError(
|
||||
f"Exact trajectory extension requires torch version "
|
||||
f"{baseline_data.get('torch_version')!r}; got {torch.__version__!r}."
|
||||
)
|
||||
|
||||
base_rec_by_seed = {r["seed"]: r for r in baseline_records}
|
||||
|
||||
# 2. Prepare full PCA dataset
|
||||
x_full_tr, y_train_3000, x_full_test, y_test_1000, data_fp = prepare_full_pca_data()
|
||||
if data_fp != expected_fp:
|
||||
raise ValueError(
|
||||
f"Data fingerprint mismatch: computed {data_fp}, baseline expected {expected_fp}"
|
||||
)
|
||||
|
||||
opt_kwargs = winner_cfg.to_optimizer_kwargs(quick=False)
|
||||
n_particles = 120
|
||||
target_epochs = 240
|
||||
batch_size = 1000
|
||||
|
||||
runs: List[Dict[str, Any]] = []
|
||||
flat_csv_rows: List[Dict[str, Any]] = []
|
||||
fidelity_passed = True
|
||||
seed_fidelity_deltas: Dict[int, float] = {}
|
||||
|
||||
# 3. Process seeds 71-75
|
||||
for seed in sorted(REPLAY_SEEDS):
|
||||
base_rec = base_rec_by_seed[seed]
|
||||
expected_model_fp = base_rec["model_fingerprint"]
|
||||
baseline_test_acc = float(base_rec["test_acc"])
|
||||
|
||||
# --- Untimed 2-Epoch Warmup Phase ---
|
||||
warmup_model = make_mnist_model(seed=seed)
|
||||
warmup_loss = nn.CrossEntropyLoss()
|
||||
warmup_opt = Optimizer(
|
||||
model=warmup_model,
|
||||
loss=warmup_loss,
|
||||
task="multiclass",
|
||||
n_particles=n_particles,
|
||||
seed=seed,
|
||||
device=device,
|
||||
**opt_kwargs,
|
||||
)
|
||||
warmup_opt.fit(
|
||||
x_full_tr,
|
||||
y_train_3000,
|
||||
epochs=2,
|
||||
batch_size=batch_size,
|
||||
renewal="loss",
|
||||
)
|
||||
sync_device(device)
|
||||
del warmup_opt, warmup_model, warmup_loss
|
||||
|
||||
# --- Timed 240-Epoch Continuous Trajectory ---
|
||||
model = make_mnist_model(seed=seed)
|
||||
model_fp = compute_model_fingerprint(model)
|
||||
fp_match = (model_fp == expected_model_fp)
|
||||
if not fp_match:
|
||||
print(
|
||||
f"[WARNING] Seed {seed} model fingerprint mismatch: "
|
||||
f"got {model_fp}, expected {expected_model_fp}"
|
||||
)
|
||||
fidelity_passed = False
|
||||
|
||||
loss_inst = nn.CrossEntropyLoss()
|
||||
opt = Optimizer(
|
||||
model=model,
|
||||
loss=loss_inst,
|
||||
task="multiclass",
|
||||
n_particles=n_particles,
|
||||
seed=seed,
|
||||
device=device,
|
||||
**opt_kwargs,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir_str:
|
||||
output_dir = Path(temp_dir_str)
|
||||
|
||||
sync_device(device)
|
||||
t0 = time.perf_counter()
|
||||
train_loss_final, train_acc_final, train_mse_final = opt.fit(
|
||||
x_full_tr,
|
||||
y_train_3000,
|
||||
epochs=target_epochs,
|
||||
batch_size=batch_size,
|
||||
renewal="loss",
|
||||
output_dir=output_dir,
|
||||
log_format="csv",
|
||||
checkpoint_interval=20,
|
||||
)
|
||||
sync_device(device)
|
||||
t1 = time.perf_counter()
|
||||
fit_time_sec = t1 - t0
|
||||
|
||||
# Read full epoch history from history.csv
|
||||
history_csv_path = output_dir / "history.csv"
|
||||
epoch_history = []
|
||||
with open(history_csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
epoch_history.append({
|
||||
"epoch": int(row["epoch"]),
|
||||
"loss": float(row["loss"]),
|
||||
"accuracy": float(row["accuracy"]),
|
||||
"mse": float(row["mse"]),
|
||||
})
|
||||
|
||||
# Track training global-best improvement epochs
|
||||
prev_best_loss = float("inf")
|
||||
improvement_count = 0
|
||||
last_improvement_epoch = 1
|
||||
for row in epoch_history:
|
||||
ep_num = row["epoch"]
|
||||
l_val = row["loss"]
|
||||
if l_val < prev_best_loss:
|
||||
improvement_count += 1
|
||||
last_improvement_epoch = ep_num
|
||||
prev_best_loss = l_val
|
||||
|
||||
# Read and evaluate checkpoints
|
||||
checkpoints: List[Dict[str, Any]] = []
|
||||
ckpt_dir = output_dir / "checkpoints"
|
||||
epoch80_test_acc = None
|
||||
|
||||
for ep in CHECKPOINT_EPOCHS:
|
||||
ckpt_path = ckpt_dir / f"epoch-{ep}.pt"
|
||||
if not ckpt_path.exists():
|
||||
raise FileNotFoundError(f"Missing checkpoint file: {ckpt_path}")
|
||||
|
||||
payload = torch.load(ckpt_path, map_location=device, weights_only=True)
|
||||
ckpt_train_loss, ckpt_train_acc, ckpt_train_mse = payload["score"]
|
||||
|
||||
# Load checkpoint state_dict into opt.eval_model and evaluate on held-out test tensor
|
||||
opt.eval_model.load_state_dict(payload["model_state_dict"])
|
||||
opt._global_best_weights = opt.codec.encode(opt.eval_model)
|
||||
test_loss, test_acc, test_mse = opt.evaluate(x_full_test, y_test_1000)
|
||||
|
||||
ckpt_record = {
|
||||
"epoch": ep,
|
||||
"train_loss": float(ckpt_train_loss),
|
||||
"train_acc": float(ckpt_train_acc),
|
||||
"train_mse": float(ckpt_train_mse),
|
||||
"test_loss": float(test_loss),
|
||||
"test_acc": float(test_acc),
|
||||
"test_mse": float(test_mse),
|
||||
}
|
||||
checkpoints.append(ckpt_record)
|
||||
|
||||
if ep == 80:
|
||||
epoch80_test_acc = float(test_acc)
|
||||
|
||||
flat_csv_rows.append({
|
||||
"seed": seed,
|
||||
"epoch": ep,
|
||||
"train_loss": float(ckpt_train_loss),
|
||||
"train_acc": float(ckpt_train_acc),
|
||||
"train_mse": float(ckpt_train_mse),
|
||||
"test_loss": float(test_loss),
|
||||
"test_acc": float(test_acc),
|
||||
"test_mse": float(test_mse),
|
||||
"fit_time_sec": round(fit_time_sec, 4),
|
||||
})
|
||||
|
||||
# Fidelity check at epoch 80 vs baseline
|
||||
assert epoch80_test_acc is not None
|
||||
delta_ep80 = abs(epoch80_test_acc - baseline_test_acc)
|
||||
seed_fidelity_deltas[seed] = delta_ep80
|
||||
|
||||
if delta_ep80 > REPLAY_TOLERANCE:
|
||||
print(
|
||||
f"[WARNING] Seed {seed} epoch 80 test accuracy delta {delta_ep80:.6f} "
|
||||
f"exceeds tolerance {REPLAY_TOLERANCE} (actual={epoch80_test_acc:.4f}, baseline={baseline_test_acc:.4f})"
|
||||
)
|
||||
fidelity_passed = False
|
||||
|
||||
runs.append({
|
||||
"seed": seed,
|
||||
"model_fingerprint": model_fp,
|
||||
"expected_model_fingerprint": expected_model_fp,
|
||||
"fingerprint_matched": fp_match,
|
||||
"baseline_epoch80_test_acc": baseline_test_acc,
|
||||
"epoch80_test_acc": epoch80_test_acc,
|
||||
"epoch80_abs_delta": delta_ep80,
|
||||
"fit_time_sec": round(fit_time_sec, 4),
|
||||
"improvement_count": improvement_count,
|
||||
"last_improvement_epoch": last_improvement_epoch,
|
||||
"checkpoints": checkpoints,
|
||||
})
|
||||
|
||||
max_ep80_delta = max(seed_fidelity_deltas.values()) if seed_fidelity_deltas else 0.0
|
||||
|
||||
# 4. Aggregations and Statistical Summaries
|
||||
checkpoint_stats: Dict[int, Dict[str, Any]] = {}
|
||||
for ep in CHECKPOINT_EPOCHS:
|
||||
ep_train_losses = [next(c["train_loss"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_train_accs = [next(c["train_acc"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_train_mses = [next(c["train_mse"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
|
||||
ep_test_losses = [next(c["test_loss"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_test_accs = [next(c["test_acc"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_test_mses = [next(c["test_mse"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
|
||||
checkpoint_stats[ep] = {
|
||||
"train_loss": calc_stats(ep_train_losses),
|
||||
"train_acc": calc_stats(ep_train_accs),
|
||||
"train_mse": calc_stats(ep_train_mses),
|
||||
"test_loss": calc_stats(ep_test_losses),
|
||||
"test_acc": calc_stats(ep_test_accs),
|
||||
"test_mse": calc_stats(ep_test_mses),
|
||||
}
|
||||
|
||||
# Endpoint paired deltas (80->240 and 200->240)
|
||||
deltas_80_to_240_train_rel = []
|
||||
deltas_80_to_240_test_acc = []
|
||||
|
||||
deltas_200_to_240_train_rel = []
|
||||
deltas_200_to_240_test_acc = []
|
||||
deltas_200_to_240_test_acc_abs = []
|
||||
|
||||
for r in runs:
|
||||
ckpt_map = {c["epoch"]: c for c in r["checkpoints"]}
|
||||
|
||||
# 80 to 240
|
||||
tl80 = ckpt_map[80]["train_loss"]
|
||||
tl240 = ckpt_map[240]["train_loss"]
|
||||
rel_red_80_240 = (tl80 - tl240) / tl80 if tl80 > 0 else 0.0
|
||||
deltas_80_to_240_train_rel.append(rel_red_80_240)
|
||||
|
||||
ta80 = ckpt_map[80]["test_acc"]
|
||||
ta240 = ckpt_map[240]["test_acc"]
|
||||
acc_delta_80_240 = ta240 - ta80
|
||||
deltas_80_to_240_test_acc.append(acc_delta_80_240)
|
||||
|
||||
# 200 to 240
|
||||
tl200 = ckpt_map[200]["train_loss"]
|
||||
rel_red_200_240 = (tl200 - tl240) / tl200 if tl200 > 0 else 0.0
|
||||
deltas_200_to_240_train_rel.append(rel_red_200_240)
|
||||
|
||||
ta200 = ckpt_map[200]["test_acc"]
|
||||
acc_delta_200_240 = ta240 - ta200
|
||||
acc_abs_change_200_240 = abs(ta240 - ta200)
|
||||
deltas_200_to_240_test_acc.append(acc_delta_200_240)
|
||||
deltas_200_to_240_test_acc_abs.append(acc_abs_change_200_240)
|
||||
|
||||
# Calculate overall mean metrics for classifications
|
||||
mean_train_loss_80 = checkpoint_stats[80]["train_loss"]["mean"]
|
||||
mean_train_loss_200 = checkpoint_stats[200]["train_loss"]["mean"]
|
||||
mean_train_loss_240 = checkpoint_stats[240]["train_loss"]["mean"]
|
||||
|
||||
mean_test_acc_80 = checkpoint_stats[80]["test_acc"]["mean"]
|
||||
mean_test_acc_200 = checkpoint_stats[200]["test_acc"]["mean"]
|
||||
mean_test_acc_240 = checkpoint_stats[240]["test_acc"]["mean"]
|
||||
|
||||
rel_train_loss_reduction_80_240 = (
|
||||
(mean_train_loss_80 - mean_train_loss_240) / mean_train_loss_80
|
||||
)
|
||||
test_acc_gain_80_240 = mean_test_acc_240 - mean_test_acc_80
|
||||
|
||||
rel_train_loss_reduction_200_240 = (
|
||||
(mean_train_loss_200 - mean_train_loss_240) / mean_train_loss_200
|
||||
)
|
||||
abs_test_acc_change_200_240 = abs(mean_test_acc_240 - mean_test_acc_200)
|
||||
|
||||
# 5. Shared Contract Predeclared Classifications
|
||||
post_80_training_converging = bool(
|
||||
rel_train_loss_reduction_80_240 >= POST80_LOSS_REDUCTION_THRESHOLD
|
||||
)
|
||||
meaningful_held_out_gain = bool(
|
||||
test_acc_gain_80_240 >= TEST_ACCURACY_GAIN_THRESHOLD
|
||||
)
|
||||
overfitting_signal = bool(
|
||||
post_80_training_converging
|
||||
and test_acc_gain_80_240 <= OVERFITTING_ACCURACY_DROP_THRESHOLD
|
||||
)
|
||||
early_stagnation = bool(
|
||||
not post_80_training_converging
|
||||
and abs(test_acc_gain_80_240) < TEST_ACCURACY_GAIN_THRESHOLD
|
||||
)
|
||||
generalization_plateau = bool(
|
||||
post_80_training_converging
|
||||
and not meaningful_held_out_gain
|
||||
and not overfitting_signal
|
||||
)
|
||||
late_plateau_diagnostic = bool(
|
||||
rel_train_loss_reduction_200_240 < LATE_LOSS_REDUCTION_THRESHOLD
|
||||
and abs_test_acc_change_200_240 < LATE_ACCURACY_CHANGE_THRESHOLD
|
||||
)
|
||||
|
||||
if meaningful_held_out_gain:
|
||||
summary_verdict = (
|
||||
f"Training beyond epoch 80 continues to improve held-out test accuracy by "
|
||||
f"{test_acc_gain_80_240 * 100:.2f} percentage points "
|
||||
f"(from {mean_test_acc_80 * 100:.2f}% to {mean_test_acc_240 * 100:.2f}%)."
|
||||
)
|
||||
elif overfitting_signal:
|
||||
summary_verdict = (
|
||||
f"Training beyond epoch 80 exhibits overfitting: training loss falls by "
|
||||
f"{rel_train_loss_reduction_80_240 * 100:.2f}% while held-out test accuracy drops by "
|
||||
f"{abs(test_acc_gain_80_240) * 100:.2f} percentage points."
|
||||
)
|
||||
elif early_stagnation:
|
||||
summary_verdict = (
|
||||
f"Optimization has effectively stagnated after epoch 80: training loss falls by only "
|
||||
f"{rel_train_loss_reduction_80_240 * 100:.2f}% and held-out accuracy changes by "
|
||||
f"{test_acc_gain_80_240 * 100:+.2f} percentage points through epoch 240."
|
||||
)
|
||||
elif generalization_plateau:
|
||||
summary_verdict = (
|
||||
f"Training loss continues to improve after epoch 80, but held-out accuracy plateaus: "
|
||||
f"{test_acc_gain_80_240 * 100:+.2f} percentage points "
|
||||
f"(from {mean_test_acc_80 * 100:.2f}% to {mean_test_acc_240 * 100:.2f}%)."
|
||||
)
|
||||
else:
|
||||
summary_verdict = (
|
||||
"The fixed endpoint criteria are inconclusive; inspect the paired checkpoint "
|
||||
"trajectory before extending the epoch horizon."
|
||||
)
|
||||
|
||||
last_improvement_epochs_dict = {r["seed"]: r["last_improvement_epoch"] for r in runs}
|
||||
paired_endpoint_deltas = []
|
||||
for r in runs:
|
||||
ckpt_map = {c["epoch"]: c for c in r["checkpoints"]}
|
||||
paired_endpoint_deltas.append(
|
||||
{
|
||||
"seed": r["seed"],
|
||||
"train_loss_relative_reduction_80_to_240": (
|
||||
ckpt_map[80]["train_loss"] - ckpt_map[240]["train_loss"]
|
||||
)
|
||||
/ ckpt_map[80]["train_loss"],
|
||||
"test_accuracy_delta_80_to_240": (
|
||||
ckpt_map[240]["test_acc"] - ckpt_map[80]["test_acc"]
|
||||
),
|
||||
"train_loss_relative_reduction_200_to_240": (
|
||||
ckpt_map[200]["train_loss"] - ckpt_map[240]["train_loss"]
|
||||
)
|
||||
/ ckpt_map[200]["train_loss"],
|
||||
"test_accuracy_delta_200_to_240": (
|
||||
ckpt_map[240]["test_acc"] - ckpt_map[200]["test_acc"]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Build final payload
|
||||
payload = {
|
||||
"epoch_convergence_protocol_version": EPOCH_CONVERGENCE_PROTOCOL_VERSION,
|
||||
"tuning_protocol_version": TUNING_PROTOCOL_VERSION,
|
||||
"pso_version": pso_version,
|
||||
"torch_version": torch.__version__,
|
||||
"hardware": hw_provenance,
|
||||
"device": device.type,
|
||||
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"baseline_path": str(baseline_path),
|
||||
"data_fingerprint": data_fp,
|
||||
"candidate_label": winner_cfg.candidate_label,
|
||||
"config": {
|
||||
**opt_kwargs,
|
||||
"n_particles": n_particles,
|
||||
"epochs": target_epochs,
|
||||
"batch_size": batch_size,
|
||||
"renewal": "loss",
|
||||
"checkpoint_interval": 20,
|
||||
},
|
||||
"contract_criteria": {
|
||||
"primary_endpoints": [80, 120, 160, 200, 240],
|
||||
"post_80_convergence_threshold_loss_reduction": POST80_LOSS_REDUCTION_THRESHOLD,
|
||||
"meaningful_gain_threshold_test_acc": TEST_ACCURACY_GAIN_THRESHOLD,
|
||||
"overfitting_threshold_test_acc": OVERFITTING_ACCURACY_DROP_THRESHOLD,
|
||||
"late_plateau_200_240_loss_threshold": LATE_LOSS_REDUCTION_THRESHOLD,
|
||||
"late_plateau_200_240_acc_threshold": LATE_ACCURACY_CHANGE_THRESHOLD,
|
||||
"replay_tolerance": REPLAY_TOLERANCE,
|
||||
},
|
||||
"fidelity_validation": {
|
||||
"replay_seeds": sorted(REPLAY_SEEDS),
|
||||
"max_epoch80_test_acc_delta": round(max_ep80_delta, 6),
|
||||
"tolerance": REPLAY_TOLERANCE,
|
||||
"passed": fidelity_passed,
|
||||
},
|
||||
"predeclared_classifications": {
|
||||
"post_80_training_converging": post_80_training_converging,
|
||||
"meaningful_held_out_gain": meaningful_held_out_gain,
|
||||
"overfitting_signal": overfitting_signal,
|
||||
"early_stagnation": early_stagnation,
|
||||
"generalization_plateau": generalization_plateau,
|
||||
"late_plateau_diagnostic": late_plateau_diagnostic,
|
||||
"summary_verdict": summary_verdict,
|
||||
},
|
||||
"summary": {
|
||||
"epochs": CHECKPOINT_EPOCHS,
|
||||
"checkpoint_stats": {str(ep): stats for ep, stats in checkpoint_stats.items()},
|
||||
"paired_deltas": {
|
||||
"80_to_240": {
|
||||
"train_loss_rel_reduction": calc_stats(deltas_80_to_240_train_rel),
|
||||
"test_acc_delta": calc_stats(deltas_80_to_240_test_acc),
|
||||
},
|
||||
"200_to_240": {
|
||||
"train_loss_rel_reduction": calc_stats(deltas_200_to_240_train_rel),
|
||||
"test_acc_delta": calc_stats(deltas_200_to_240_test_acc),
|
||||
"test_acc_abs_change": calc_stats(deltas_200_to_240_test_acc_abs),
|
||||
},
|
||||
},
|
||||
"paired_endpoint_deltas_by_seed": paired_endpoint_deltas,
|
||||
"last_training_best_improvement_epochs": last_improvement_epochs_dict,
|
||||
},
|
||||
"runs": runs,
|
||||
"completed": True,
|
||||
"valid": fidelity_passed,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Write output JSON
|
||||
save_json_atomic(payload, output_json_path)
|
||||
print(f"Saved analysis JSON to {output_json_path}")
|
||||
|
||||
# Write output CSV
|
||||
output_csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
fieldnames = [
|
||||
"seed",
|
||||
"epoch",
|
||||
"train_loss",
|
||||
"train_acc",
|
||||
"train_mse",
|
||||
"test_loss",
|
||||
"test_acc",
|
||||
"test_mse",
|
||||
"fit_time_sec",
|
||||
]
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(flat_csv_rows)
|
||||
print(f"Saved flat checkpoint CSV to {output_csv_path}")
|
||||
|
||||
# 6. Render Figure
|
||||
render_convergence_figure(CHECKPOINT_EPOCHS, runs, checkpoint_stats, figure_path)
|
||||
print(f"Rendered plot to {figure_path}")
|
||||
|
||||
# Print summary block
|
||||
print("\n" + "=" * 70)
|
||||
print("EPOCH CONVERGENCE & TRAJECTORY ANALYSIS RESULTS")
|
||||
print("=" * 70)
|
||||
print(f"Device: {device.type} | Seeds: {sorted(REPLAY_SEEDS)}")
|
||||
print(f"Fidelity Replay Check (Epoch 80 <= {REPLAY_TOLERANCE}): Max Delta = {max_ep80_delta:.6f} -> Passed: {fidelity_passed}")
|
||||
print("-" * 70)
|
||||
print("Predeclared Classifications (Epoch 80 -> 240):")
|
||||
print(f" Post-80 Training Converging (Loss Drop >= 1%): {post_80_training_converging} ({rel_train_loss_reduction_80_240 * 100:.2f}%)")
|
||||
print(f" Meaningful Held-Out Gain (Acc Rise >= 1%p): {meaningful_held_out_gain} ({test_acc_gain_80_240 * 100:+.2f}%p)")
|
||||
print(f" Overfitting Signal: {overfitting_signal}")
|
||||
print(f" Early Stagnation: {early_stagnation}")
|
||||
print(f" Generalization Plateau: {generalization_plateau}")
|
||||
print(f" Late Plateau Diagnostic (200 -> 240): {late_plateau_diagnostic}")
|
||||
print("-" * 70)
|
||||
print(f"Verdict: {summary_verdict}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
return fidelity_passed
|
||||
|
||||
|
||||
def render_convergence_figure(
|
||||
epochs: List[int],
|
||||
runs: List[Dict[str, Any]],
|
||||
checkpoint_stats: Dict[int, Dict[str, Any]],
|
||||
figure_path: Path,
|
||||
):
|
||||
figure_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5.5))
|
||||
|
||||
mean_train_loss = [checkpoint_stats[ep]["train_loss"]["mean"] for ep in epochs]
|
||||
std_train_loss = [checkpoint_stats[ep]["train_loss"]["std"] for ep in epochs]
|
||||
|
||||
mean_test_acc = [checkpoint_stats[ep]["test_acc"]["mean"] for ep in epochs]
|
||||
std_test_acc = [checkpoint_stats[ep]["test_acc"]["std"] for ep in epochs]
|
||||
|
||||
epochs_arr = np.array(epochs)
|
||||
mean_tl_arr = np.array(mean_train_loss)
|
||||
std_tl_arr = np.array(std_train_loss)
|
||||
|
||||
mean_ta_arr = np.array(mean_test_acc)
|
||||
std_ta_arr = np.array(std_test_acc)
|
||||
|
||||
# Subplot 1: Training Loss
|
||||
for r in runs:
|
||||
r_epochs = [c["epoch"] for c in r["checkpoints"]]
|
||||
r_losses = [c["train_loss"] for c in r["checkpoints"]]
|
||||
ax1.plot(r_epochs, r_losses, color="#1f77b4", alpha=0.25, linestyle=":", linewidth=1.2)
|
||||
|
||||
ax1.plot(epochs_arr, mean_tl_arr, marker="o", color="#1f77b4", linewidth=2.2, label="Mean Training Loss")
|
||||
ax1.fill_between(
|
||||
epochs_arr,
|
||||
mean_tl_arr - std_tl_arr,
|
||||
mean_tl_arr + std_tl_arr,
|
||||
color="#1f77b4",
|
||||
alpha=0.15,
|
||||
label="±1 Std Dev",
|
||||
)
|
||||
ax1.axvline(80, color="#d62728", linestyle="--", linewidth=1.5, label="Baseline Horizon (Epoch 80)")
|
||||
ax1.set_xlabel("Epoch", fontsize=11)
|
||||
ax1.set_ylabel("Global-Best Training Loss", fontsize=11)
|
||||
ax1.set_title("Global-Best Training Loss Trajectory", fontsize=12, fontweight="bold")
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.legend(loc="upper right", frameon=True)
|
||||
|
||||
# Subplot 2: Held-Out Test Accuracy
|
||||
for r in runs:
|
||||
r_epochs = [c["epoch"] for c in r["checkpoints"]]
|
||||
r_accs = [c["test_acc"] for c in r["checkpoints"]]
|
||||
ax2.plot(r_epochs, r_accs, color="#2ca02c", alpha=0.25, linestyle=":", linewidth=1.2)
|
||||
|
||||
ax2.plot(epochs_arr, mean_ta_arr, marker="s", color="#2ca02c", linewidth=2.2, label="Mean Test Accuracy")
|
||||
ax2.fill_between(
|
||||
epochs_arr,
|
||||
mean_ta_arr - std_ta_arr,
|
||||
mean_ta_arr + std_ta_arr,
|
||||
color="#2ca02c",
|
||||
alpha=0.15,
|
||||
label="±1 Std Dev",
|
||||
)
|
||||
ax2.axvline(80, color="#d62728", linestyle="--", linewidth=1.5, label="Baseline Horizon (Epoch 80)")
|
||||
ax2.set_xlabel("Epoch", fontsize=11)
|
||||
ax2.set_ylabel("Held-Out Test Accuracy", fontsize=11)
|
||||
ax2.set_title("Held-Out Test Accuracy Trajectory", fontsize=12, fontweight="bold")
|
||||
ax2.grid(True, alpha=0.3)
|
||||
ax2.legend(loc="lower right", frameon=True)
|
||||
|
||||
fig.suptitle(
|
||||
"Adaptive Moment 120-Particle MNIST 240-Epoch Convergence & Trajectory Analysis",
|
||||
fontsize=13,
|
||||
fontweight="bold",
|
||||
)
|
||||
plt.tight_layout(rect=(0.0, 0.0, 1.0, 0.95))
|
||||
plt.savefig(figure_path, dpi=300, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Adaptive Moment 120-Particle x 240-Epoch MNIST Convergence Analysis"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_tuning.json"),
|
||||
help="Path to baseline tuning JSON file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_epoch_convergence.json"),
|
||||
help="Path to output analysis JSON file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-csv",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_epoch_convergence.csv"),
|
||||
help="Path to output checkpoint CSV file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--figure",
|
||||
type=Path,
|
||||
default=Path("history_plt/pso_v4_epoch_convergence.png"),
|
||||
help="Path to output convergence PNG figure",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Execution device (mps, cuda, cpu; default: auto-detect)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
passed = run_epoch_convergence_analysis(
|
||||
baseline_path=args.baseline_json,
|
||||
output_json_path=args.output_json,
|
||||
output_csv_path=args.output_csv,
|
||||
figure_path=args.figure,
|
||||
device_str=args.device,
|
||||
)
|
||||
|
||||
if not passed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
Strict Pareto Evaluator for Heavy Task PSO Autoresearch.
|
||||
|
||||
Evaluates candidate equalized signed-hash subspace experiment artifacts against
|
||||
baseline heavy task benchmark results (benchmark_results/pso_v6_heavy_tasks.json).
|
||||
|
||||
Baseline Policy:
|
||||
- mnist_compact: G8
|
||||
- mnist_wide: G5
|
||||
- fashion_compact: G8
|
||||
- fashion_wide: G5
|
||||
|
||||
Evaluates 7 Hard Gates:
|
||||
1. Finite Metrics (all validation metrics finite across runs)
|
||||
2. Test-Sealed (zero official test data loaded & 0 test evaluations)
|
||||
3. Config Matched (12 particles, 80 epochs, 10k subset, seeds 101-103)
|
||||
4. State Ratio Boundary (max state ratio <= 0.5 across all workloads)
|
||||
5. Workload Accuracy Regression Boundary (each workload acc regression <= 1.0 pp)
|
||||
6. Workload NLL Regression Boundary (each workload NLL regression <= 5.0%)
|
||||
7. Worst-Workload (mnist_wide) Improvement (acc gain >= 2.0 pp OR NLL reduction >= 5.0%)
|
||||
|
||||
Numeric Score:
|
||||
Score = mean_rel_nll_reduction_pct + mean_acc_gain_pp + 10 * log2(1 / max_state_ratio) - 100 * failed_gate_count
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
# Ensure test directory and repo root are in Python path
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent if Path(__file__).resolve().parent.name != "PSO" else Path(__file__).resolve().parent
|
||||
TEST_DIR = REPO_ROOT / "test"
|
||||
if str(TEST_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TEST_DIR))
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from benchmark_suite import save_json_atomic
|
||||
from heavy_pso_autoresearch import compute_latent_dim, compute_core_swarm_state_bytes
|
||||
EVALUATOR_VERSION = "EVALUATE-HEAVY-AUTORESEARCH 1.0.0"
|
||||
|
||||
BASELINE_POLICY: Dict[str, str] = {
|
||||
"mnist_compact": "G8",
|
||||
"mnist_wide": "G5",
|
||||
"fashion_compact": "G8",
|
||||
"fashion_wide": "G5",
|
||||
}
|
||||
|
||||
EXPECTED_SEEDS = [101, 102, 103]
|
||||
EXPECTED_PARTICLES = 12
|
||||
EXPECTED_EPOCHS = 80
|
||||
EXPECTED_SUBSET_SIZE = 10000
|
||||
EXPECTED_WORKLOADS = ["mnist_compact", "mnist_wide", "fashion_compact", "fashion_wide"]
|
||||
|
||||
|
||||
def evaluate_heavy_autoresearch(
|
||||
baseline_path: Union[str, Path],
|
||||
candidate_path: Union[str, Path],
|
||||
output_path: Optional[Union[str, Path]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Evaluates a candidate experiment artifact against baseline heavy task results.
|
||||
Validates schemas, configurations, seeds, test-sealed constraints, finiteness,
|
||||
calculates every hard gate and numeric score, selects the winning candidate,
|
||||
and returns a structured evaluator payload.
|
||||
"""
|
||||
baseline_path = Path(baseline_path)
|
||||
candidate_path = Path(candidate_path)
|
||||
|
||||
if not baseline_path.is_file():
|
||||
raise FileNotFoundError(f"Baseline artifact not found at '{baseline_path}'")
|
||||
if not candidate_path.is_file():
|
||||
raise FileNotFoundError(f"Candidate artifact not found at '{candidate_path}'")
|
||||
|
||||
with open(baseline_path, "r", encoding="utf-8") as f:
|
||||
baseline_data = json.load(f)
|
||||
|
||||
with open(candidate_path, "r", encoding="utf-8") as f:
|
||||
candidate_data = json.load(f)
|
||||
|
||||
WORKLOAD_TOTAL_DIMS: Dict[str, int] = {
|
||||
"mnist_compact": 9098,
|
||||
"mnist_wide": 55338,
|
||||
"fashion_compact": 9098,
|
||||
"fashion_wide": 55338,
|
||||
}
|
||||
|
||||
if (
|
||||
baseline_data.get("official_test_data_loaded") is not False
|
||||
or baseline_data.get("official_test_evaluations") != 0
|
||||
):
|
||||
raise ValueError("Baseline artifact must explicitly seal official test data.")
|
||||
|
||||
# Validate baseline JSON schema & confirmation results
|
||||
if "confirmation_results" not in baseline_data:
|
||||
raise KeyError("Baseline JSON missing top-level 'confirmation_results' key")
|
||||
|
||||
baseline_confirm = baseline_data["confirmation_results"]
|
||||
baseline_metrics: Dict[str, Dict[str, float]] = {}
|
||||
baseline_core_bytes: Dict[str, int] = {}
|
||||
|
||||
for wl in EXPECTED_WORKLOADS:
|
||||
if wl not in baseline_confirm:
|
||||
raise KeyError(f"Baseline confirmation results missing workload '{wl}'")
|
||||
selected_method = BASELINE_POLICY[wl]
|
||||
if selected_method not in baseline_confirm[wl]:
|
||||
raise KeyError(
|
||||
f"Baseline confirmation results for '{wl}' missing policy method '{selected_method}'"
|
||||
)
|
||||
entry = baseline_confirm[wl][selected_method]
|
||||
stats = entry.get("stats", {}) if isinstance(entry, dict) else {}
|
||||
val_nll_mean = float(stats.get("val_nll", {}).get("mean", float("nan")))
|
||||
val_acc_mean = float(stats.get("val_acc", {}).get("mean", float("nan")))
|
||||
if not (math.isfinite(val_nll_mean) and math.isfinite(val_acc_mean)):
|
||||
raise ValueError(f"Baseline metrics for '{wl}' method '{selected_method}' contain NaN or non-finite value")
|
||||
|
||||
baseline_metrics[wl] = {
|
||||
"val_nll": val_nll_mean,
|
||||
"val_acc": val_acc_mean,
|
||||
"val_brier": float(stats.get("val_brier", {}).get("mean", 0.0) or 0.0),
|
||||
"val_ece": float(stats.get("val_ece", {}).get("mean", 0.0) or 0.0),
|
||||
"method_id": selected_method,
|
||||
}
|
||||
|
||||
bytes_list = []
|
||||
if (
|
||||
isinstance(entry, dict)
|
||||
and isinstance(entry.get("per_seed_runs"), list)
|
||||
):
|
||||
bytes_list = [
|
||||
int(run["core_swarm_state_bytes"])
|
||||
for run in entry["per_seed_runs"]
|
||||
if (
|
||||
isinstance(run, dict)
|
||||
and "core_swarm_state_bytes" in run
|
||||
and math.isfinite(float(run["core_swarm_state_bytes"]))
|
||||
)
|
||||
]
|
||||
if bytes_list and len(set(bytes_list)) != 1:
|
||||
raise ValueError(f"Baseline core-state bytes vary across seeds for '{wl}'.")
|
||||
if bytes_list:
|
||||
b_bytes = bytes_list[0]
|
||||
else:
|
||||
states = 5 * EXPECTED_PARTICLES + (1 if selected_method == "G8" else 0)
|
||||
b_bytes = states * WORKLOAD_TOTAL_DIMS[wl] * 4
|
||||
baseline_core_bytes[wl] = b_bytes
|
||||
|
||||
# Validate candidate JSON schema
|
||||
if "candidate_runs" not in candidate_data:
|
||||
raise KeyError("Candidate JSON missing top-level 'candidate_runs' key")
|
||||
|
||||
candidate_runs = candidate_data["candidate_runs"]
|
||||
if not isinstance(candidate_runs, dict) or len(candidate_runs) == 0:
|
||||
raise ValueError("Candidate JSON 'candidate_runs' must be a non-empty dictionary")
|
||||
|
||||
# Global candidate test-sealed checks
|
||||
top_test_loaded_present = "official_test_data_loaded" in candidate_data
|
||||
top_test_loaded_val = candidate_data.get("official_test_data_loaded")
|
||||
top_test_evals_present = "official_test_evaluations" in candidate_data
|
||||
top_test_evals_val = candidate_data.get("official_test_evaluations")
|
||||
|
||||
top_test_sealed = (
|
||||
top_test_loaded_present
|
||||
and top_test_loaded_val is False
|
||||
and top_test_evals_present
|
||||
and top_test_evals_val == 0
|
||||
)
|
||||
|
||||
candidate_evaluations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for cand_id, wl_map in candidate_runs.items():
|
||||
gate_finite = True
|
||||
gate_test_sealed = bool(top_test_sealed)
|
||||
gate_config_matched = True
|
||||
|
||||
if not isinstance(wl_map, dict):
|
||||
gate_config_matched = False
|
||||
gate_finite = False
|
||||
wl_map = {}
|
||||
|
||||
if set(wl_map) != set(EXPECTED_WORKLOADS):
|
||||
gate_config_matched = False
|
||||
|
||||
for wl in EXPECTED_WORKLOADS:
|
||||
if wl not in wl_map:
|
||||
gate_config_matched = False
|
||||
gate_finite = False
|
||||
|
||||
derived_ratios: Dict[str, float] = {}
|
||||
per_workload_deltas: Dict[str, Dict[str, float]] = {}
|
||||
acc_regressions_valid = True
|
||||
nll_regressions_valid = True
|
||||
|
||||
for wl in EXPECTED_WORKLOADS:
|
||||
if wl not in wl_map or not isinstance(wl_map[wl], dict):
|
||||
acc_regressions_valid = False
|
||||
nll_regressions_valid = False
|
||||
continue
|
||||
|
||||
wl_entry = wl_map[wl]
|
||||
p_val = wl_entry.get("particles")
|
||||
e_val = wl_entry.get("epochs")
|
||||
sub_val = wl_entry.get("subset_size")
|
||||
seeds_val = wl_entry.get("seeds")
|
||||
|
||||
if (
|
||||
p_val != EXPECTED_PARTICLES
|
||||
or e_val != EXPECTED_EPOCHS
|
||||
or sub_val != EXPECTED_SUBSET_SIZE
|
||||
or seeds_val != EXPECTED_SEEDS
|
||||
):
|
||||
gate_config_matched = False
|
||||
|
||||
per_seed = wl_entry.get("per_seed_runs")
|
||||
if not isinstance(per_seed, list) or len(per_seed) != len(EXPECTED_SEEDS):
|
||||
gate_config_matched = False
|
||||
gate_finite = False
|
||||
actual_seeds = []
|
||||
else:
|
||||
actual_seeds = [
|
||||
s_run.get("seed") for s_run in per_seed if isinstance(s_run, dict) and "seed" in s_run
|
||||
]
|
||||
if actual_seeds != EXPECTED_SEEDS:
|
||||
gate_config_matched = False
|
||||
|
||||
expected_queries = EXPECTED_PARTICLES * EXPECTED_EPOCHS
|
||||
expected_samples = expected_queries * EXPECTED_SUBSET_SIZE
|
||||
for s_run in per_seed:
|
||||
if (
|
||||
not isinstance(s_run, dict)
|
||||
or s_run.get("total_queries") != expected_queries
|
||||
or s_run.get("total_sample_evaluations") != expected_samples
|
||||
):
|
||||
gate_config_matched = False
|
||||
|
||||
stats = wl_entry.get("stats") if isinstance(wl_entry.get("stats"), dict) else {}
|
||||
val_nll_dict = stats.get("val_nll") if isinstance(stats.get("val_nll"), dict) else {}
|
||||
val_acc_dict = stats.get("val_acc") if isinstance(stats.get("val_acc"), dict) else {}
|
||||
|
||||
c_nll_raw = val_nll_dict.get("mean")
|
||||
c_acc_raw = val_acc_dict.get("mean")
|
||||
|
||||
if (
|
||||
c_nll_raw is None
|
||||
or c_acc_raw is None
|
||||
or not isinstance(c_nll_raw, (int, float))
|
||||
or not isinstance(c_acc_raw, (int, float))
|
||||
or not (math.isfinite(float(c_nll_raw)) and math.isfinite(float(c_acc_raw)))
|
||||
):
|
||||
gate_finite = False
|
||||
c_nll = float("nan")
|
||||
c_acc = float("nan")
|
||||
else:
|
||||
c_nll = float(c_nll_raw)
|
||||
c_acc = float(c_acc_raw)
|
||||
|
||||
if isinstance(per_seed, list):
|
||||
for s_run in per_seed:
|
||||
if not isinstance(s_run, dict):
|
||||
gate_finite = False
|
||||
gate_config_matched = False
|
||||
continue
|
||||
if "official_test_evaluations" not in s_run or s_run["official_test_evaluations"] != 0:
|
||||
gate_test_sealed = False
|
||||
if s_run.get("is_finite") is not True:
|
||||
gate_finite = False
|
||||
|
||||
s_nll = s_run.get("val_selected_loss")
|
||||
s_acc = s_run.get("val_selected_acc")
|
||||
g_nll = s_run.get("gbest_loss")
|
||||
g_acc = s_run.get("gbest_acc")
|
||||
w_time = s_run.get("wall_time_sec")
|
||||
val_m = s_run.get("val_metrics")
|
||||
|
||||
val_m_ok = (
|
||||
isinstance(val_m, dict)
|
||||
and len(val_m) > 0
|
||||
and all(
|
||||
isinstance(v, (int, float)) and math.isfinite(float(v))
|
||||
for v in val_m.values()
|
||||
)
|
||||
)
|
||||
|
||||
scalars_for_s_run = [s_nll, s_acc, g_nll, g_acc, w_time]
|
||||
s_scalars_ok = all(
|
||||
v is not None and isinstance(v, (int, float)) and math.isfinite(float(v))
|
||||
for v in scalars_for_s_run
|
||||
)
|
||||
|
||||
if not (val_m_ok and s_scalars_ok):
|
||||
gate_finite = False
|
||||
|
||||
expected_total_dim = WORKLOAD_TOTAL_DIMS[wl]
|
||||
total_dim = wl_entry.get("total_dim")
|
||||
raw_ratio = wl_entry.get("ratio")
|
||||
if (
|
||||
total_dim != expected_total_dim
|
||||
or not isinstance(raw_ratio, (int, float))
|
||||
or not (0.0 < float(raw_ratio) <= 1.0)
|
||||
):
|
||||
gate_config_matched = False
|
||||
total_dim = expected_total_dim
|
||||
raw_ratio = 1.0
|
||||
|
||||
latent_dim = compute_latent_dim(total_dim, float(raw_ratio))
|
||||
if wl_entry.get("latent_dim") != latent_dim:
|
||||
gate_config_matched = False
|
||||
|
||||
analytical_cand_bytes = compute_core_swarm_state_bytes(EXPECTED_PARTICLES, latent_dim)
|
||||
b_bytes = baseline_core_bytes[wl]
|
||||
|
||||
derived_ratio = float(analytical_cand_bytes) / float(b_bytes)
|
||||
derived_ratios[wl] = derived_ratio
|
||||
|
||||
if wl_entry.get("core_swarm_state_bytes") != analytical_cand_bytes:
|
||||
gate_config_matched = False
|
||||
|
||||
if isinstance(per_seed, list):
|
||||
for s_run in per_seed:
|
||||
if isinstance(s_run, dict) and s_run.get("core_swarm_state_bytes") != analytical_cand_bytes:
|
||||
gate_config_matched = False
|
||||
|
||||
reported_state_ratio = wl_entry.get("state_ratio")
|
||||
if (
|
||||
not isinstance(reported_state_ratio, (int, float))
|
||||
or not math.isfinite(float(reported_state_ratio))
|
||||
or not math.isclose(
|
||||
float(reported_state_ratio),
|
||||
derived_ratio,
|
||||
rel_tol=1e-5,
|
||||
abs_tol=1e-5,
|
||||
)
|
||||
):
|
||||
gate_config_matched = False
|
||||
|
||||
b_metrics = baseline_metrics[wl]
|
||||
b_nll = b_metrics["val_nll"]
|
||||
b_acc = b_metrics["val_acc"]
|
||||
|
||||
if math.isfinite(c_nll) and math.isfinite(c_acc) and math.isfinite(b_nll) and math.isfinite(b_acc):
|
||||
nll_delta = c_nll - b_nll
|
||||
rel_nll_reduction_pct = ((b_nll - c_nll) / b_nll) * 100.0 if b_nll > 0 else 0.0
|
||||
acc_gain_pp = c_acc - b_acc
|
||||
|
||||
per_workload_deltas[wl] = {
|
||||
"state_ratio": derived_ratio,
|
||||
"baseline_nll": b_nll,
|
||||
"candidate_nll": c_nll,
|
||||
"nll_delta": nll_delta,
|
||||
"rel_nll_reduction_pct": rel_nll_reduction_pct,
|
||||
"baseline_acc": b_acc,
|
||||
"candidate_acc": c_acc,
|
||||
"acc_gain_pp": acc_gain_pp,
|
||||
}
|
||||
|
||||
if acc_gain_pp < -1.0:
|
||||
acc_regressions_valid = False
|
||||
if rel_nll_reduction_pct < -5.0:
|
||||
nll_regressions_valid = False
|
||||
else:
|
||||
acc_regressions_valid = False
|
||||
nll_regressions_valid = False
|
||||
gate_finite = False
|
||||
|
||||
if derived_ratios and len(derived_ratios) == len(EXPECTED_WORKLOADS):
|
||||
max_state_ratio = max(derived_ratios.values())
|
||||
else:
|
||||
max_state_ratio = 1.0
|
||||
|
||||
gate_state_ratio = bool(0.0 < max_state_ratio <= 0.5 and math.isfinite(max_state_ratio))
|
||||
gate_acc_regression = bool(acc_regressions_valid)
|
||||
gate_nll_regression = bool(nll_regressions_valid)
|
||||
|
||||
if "mnist_wide" in per_workload_deltas:
|
||||
mw_delta = per_workload_deltas["mnist_wide"]
|
||||
mw_acc_gain = mw_delta["acc_gain_pp"]
|
||||
mw_nll_red = mw_delta["rel_nll_reduction_pct"]
|
||||
gate_baseline_worst_improvement = bool((mw_acc_gain >= 2.0) or (mw_nll_red >= 5.0))
|
||||
else:
|
||||
gate_baseline_worst_improvement = False
|
||||
|
||||
gates = {
|
||||
"gate_finite": bool(gate_finite),
|
||||
"gate_test_sealed": bool(gate_test_sealed),
|
||||
"gate_config_matched": bool(gate_config_matched),
|
||||
"gate_state_ratio": bool(gate_state_ratio),
|
||||
"gate_acc_regression": bool(gate_acc_regression),
|
||||
"gate_nll_regression": bool(gate_nll_regression),
|
||||
"gate_baseline_worst_improvement": bool(gate_baseline_worst_improvement),
|
||||
}
|
||||
|
||||
failed_gates = [g_name for g_name, g_pass in gates.items() if not g_pass]
|
||||
failed_gate_count = len(failed_gates)
|
||||
cand_pass = bool(failed_gate_count == 0)
|
||||
|
||||
if len(per_workload_deltas) == len(EXPECTED_WORKLOADS):
|
||||
mean_rel_nll_reduction_pct = float(
|
||||
sum(d["rel_nll_reduction_pct"] for d in per_workload_deltas.values())
|
||||
/ len(per_workload_deltas)
|
||||
)
|
||||
mean_acc_gain_pp = float(
|
||||
sum(d["acc_gain_pp"] for d in per_workload_deltas.values())
|
||||
/ len(per_workload_deltas)
|
||||
)
|
||||
else:
|
||||
mean_rel_nll_reduction_pct = 0.0
|
||||
mean_acc_gain_pp = 0.0
|
||||
|
||||
if 0.0 < max_state_ratio <= 1.0 and math.isfinite(max_state_ratio):
|
||||
state_efficiency_bonus = 10.0 * math.log2(1.0 / max_state_ratio)
|
||||
else:
|
||||
state_efficiency_bonus = 0.0
|
||||
|
||||
score = (
|
||||
mean_rel_nll_reduction_pct
|
||||
+ mean_acc_gain_pp
|
||||
+ state_efficiency_bonus
|
||||
- (100.0 * failed_gate_count)
|
||||
)
|
||||
|
||||
if not math.isfinite(score):
|
||||
score = -100.0 * max(1, failed_gate_count)
|
||||
|
||||
candidate_evaluations[cand_id] = {
|
||||
"candidate_id": cand_id,
|
||||
"pass": cand_pass,
|
||||
"score": float(score),
|
||||
"failed_gates": failed_gates,
|
||||
"failed_gate_count": failed_gate_count,
|
||||
"gate_details": gates,
|
||||
"per_workload": per_workload_deltas,
|
||||
"state_ratios": derived_ratios,
|
||||
"summary_metrics": {
|
||||
"mean_rel_nll_reduction_pct": mean_rel_nll_reduction_pct,
|
||||
"mean_acc_gain_pp": mean_acc_gain_pp,
|
||||
"max_state_ratio": max_state_ratio,
|
||||
"state_efficiency_bonus": state_efficiency_bonus,
|
||||
},
|
||||
}
|
||||
|
||||
passing_cand_ids = [
|
||||
c_id for c_id, c_eval in candidate_evaluations.items() if c_eval["pass"]
|
||||
]
|
||||
|
||||
if passing_cand_ids:
|
||||
selected_candidate_id = max(
|
||||
passing_cand_ids, key=lambda c_id: candidate_evaluations[c_id]["score"]
|
||||
)
|
||||
overall_pass = True
|
||||
else:
|
||||
selected_candidate_id = max(
|
||||
candidate_evaluations.keys(),
|
||||
key=lambda c_id: candidate_evaluations[c_id]["score"],
|
||||
)
|
||||
overall_pass = False
|
||||
|
||||
selected_eval = candidate_evaluations[selected_candidate_id]
|
||||
|
||||
evaluator_output = {
|
||||
"pass": overall_pass,
|
||||
"score": float(selected_eval["score"]),
|
||||
"selected_candidate_id": selected_candidate_id,
|
||||
"evaluator_version": EVALUATOR_VERSION,
|
||||
"source_paths": {
|
||||
"baseline_path": str(baseline_path),
|
||||
"candidate_path": str(candidate_path),
|
||||
},
|
||||
"baseline_policy": BASELINE_POLICY,
|
||||
"candidate_evaluations": candidate_evaluations,
|
||||
"per_workload_deltas": selected_eval["per_workload"],
|
||||
"state_ratios": selected_eval["state_ratios"],
|
||||
}
|
||||
|
||||
if output_path is not None:
|
||||
save_json_atomic(evaluator_output, Path(output_path))
|
||||
|
||||
return evaluator_output
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Strict Pareto Evaluator for Heavy Task PSO Autoresearch"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
type=str,
|
||||
default="benchmark_results/pso_v6_heavy_tasks.json",
|
||||
help="Path to baseline heavy tasks JSON artifact",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--candidate",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to candidate heavy autoresearch JSON artifact",
|
||||
)
|
||||
parser.add_argument(
|
||||
"candidate_pos",
|
||||
nargs="?",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Positional candidate JSON path fallback",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional path to write evaluator result JSON",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
cand_path = args.candidate or args.candidate_pos
|
||||
if not cand_path:
|
||||
parser.error("Candidate JSON path must be supplied via --candidate or positional argument.")
|
||||
|
||||
out_path = Path(args.output) if args.output else None
|
||||
|
||||
result = evaluate_heavy_autoresearch(
|
||||
baseline_path=args.baseline,
|
||||
candidate_path=cand_path,
|
||||
output_path=out_path,
|
||||
)
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,741 @@
|
||||
"""Strict evaluator for the Heavy PSO cross-split robustness mission.
|
||||
|
||||
Every candidate is compared with a baseline rerun on the same train/validation
|
||||
partition and swarm seeds. Official test data must remain sealed. Development
|
||||
may qualify a policy for one-shot confirmation, but mission ``pass`` is true
|
||||
only when both phases satisfy the frozen evaluator contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEST_DIR = REPO_ROOT / "test"
|
||||
if str(TEST_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TEST_DIR))
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from benchmark_suite import save_json_atomic
|
||||
from heavy_pso_autoresearch import compute_core_swarm_state_bytes, compute_latent_dim
|
||||
|
||||
EVALUATOR_VERSION = "HEAVY-PSO-CROSS-SPLIT-EVALUATOR 1.0.0"
|
||||
EXPECTED_PARTICLES = 12
|
||||
EXPECTED_EPOCHS = 80
|
||||
EXPECTED_SUBSET_SIZE = 10000
|
||||
EXPECTED_QUERIES = EXPECTED_PARTICLES * EXPECTED_EPOCHS
|
||||
EXPECTED_SAMPLES = EXPECTED_QUERIES * EXPECTED_SUBSET_SIZE
|
||||
EXPECTED_DEV_SPLIT_SEEDS = [20260905, 20260906]
|
||||
EXPECTED_DEV_SWARM_SEEDS = [101, 102, 103]
|
||||
EXPECTED_CONF_SPLIT_SEEDS = [20260907]
|
||||
EXPECTED_CONF_SWARM_SEEDS = [111, 112, 113]
|
||||
WORKLOADS = ["mnist_compact", "mnist_wide", "fashion_compact", "fashion_wide"]
|
||||
BASELINE_METHODS = {
|
||||
"mnist_compact": "G8",
|
||||
"mnist_wide": "G5",
|
||||
"fashion_compact": "G8",
|
||||
"fashion_wide": "G5",
|
||||
}
|
||||
TOTAL_DIMS = {
|
||||
"mnist_compact": 9098,
|
||||
"mnist_wide": 55338,
|
||||
"fashion_compact": 9098,
|
||||
"fashion_wide": 55338,
|
||||
}
|
||||
PHASE_SPECS = {
|
||||
"development": (EXPECTED_DEV_SPLIT_SEEDS, EXPECTED_DEV_SWARM_SEEDS),
|
||||
"confirmation": (EXPECTED_CONF_SPLIT_SEEDS, EXPECTED_CONF_SWARM_SEEDS),
|
||||
}
|
||||
|
||||
|
||||
def load_artifact(path: Path) -> Dict[str, Any]:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Artifact file not found: {path}")
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Artifact at {path} must be a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def _is_finite_number(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
|
||||
|
||||
def _mean(values: Sequence[float]) -> float:
|
||||
return float(math.fsum(values) / len(values))
|
||||
|
||||
|
||||
def _append_issue(issues: Dict[str, List[str]], category: str, message: str) -> None:
|
||||
issues[category].append(message)
|
||||
|
||||
|
||||
def _validate_stats(
|
||||
entry: Dict[str, Any],
|
||||
per_seed_runs: List[Dict[str, Any]],
|
||||
label: str,
|
||||
issues: Dict[str, List[str]],
|
||||
) -> Optional[Tuple[float, float]]:
|
||||
stats = entry.get("stats")
|
||||
if not isinstance(stats, dict):
|
||||
_append_issue(issues, "schema", f"{label}: missing stats object")
|
||||
return None
|
||||
|
||||
try:
|
||||
acc_mean = stats["val_acc"]["mean"]
|
||||
nll_mean = stats["val_nll"]["mean"]
|
||||
except (KeyError, TypeError):
|
||||
_append_issue(issues, "schema", f"{label}: missing val_acc/val_nll means")
|
||||
return None
|
||||
|
||||
if not (_is_finite_number(acc_mean) and _is_finite_number(nll_mean)):
|
||||
_append_issue(issues, "finite", f"{label}: non-finite aggregate metrics")
|
||||
return None
|
||||
|
||||
if len(per_seed_runs) > 0:
|
||||
run_accs: List[float] = []
|
||||
run_nlls: List[float] = []
|
||||
for run in per_seed_runs:
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
seed_label = f"{label}/seed={run.get('seed')}"
|
||||
|
||||
if "val_selected_acc" not in run or run.get("val_selected_acc") is None:
|
||||
_append_issue(issues, "schema", f"{seed_label}: missing val_selected_acc")
|
||||
else:
|
||||
val_acc = run["val_selected_acc"]
|
||||
if not isinstance(val_acc, (int, float)) or isinstance(val_acc, bool):
|
||||
_append_issue(issues, "schema", f"{seed_label}: non-numeric val_selected_acc")
|
||||
elif not math.isfinite(float(val_acc)):
|
||||
_append_issue(issues, "finite", f"{seed_label}: non-finite val_selected_acc")
|
||||
else:
|
||||
run_accs.append(float(val_acc))
|
||||
|
||||
if "val_selected_loss" not in run or run.get("val_selected_loss") is None:
|
||||
_append_issue(issues, "schema", f"{seed_label}: missing val_selected_loss")
|
||||
else:
|
||||
val_nll = run["val_selected_loss"]
|
||||
if not isinstance(val_nll, (int, float)) or isinstance(val_nll, bool):
|
||||
_append_issue(issues, "schema", f"{seed_label}: non-numeric val_selected_loss")
|
||||
elif not math.isfinite(float(val_nll)):
|
||||
_append_issue(issues, "finite", f"{seed_label}: non-finite val_selected_loss")
|
||||
else:
|
||||
run_nlls.append(float(val_nll))
|
||||
|
||||
if len(run_accs) == len(per_seed_runs):
|
||||
if not math.isclose(float(acc_mean), _mean(run_accs), rel_tol=1e-6, abs_tol=1e-5):
|
||||
_append_issue(issues, "schema", f"{label}: val_acc mean disagrees with per-seed runs")
|
||||
if len(run_nlls) == len(per_seed_runs):
|
||||
if not math.isclose(float(nll_mean), _mean(run_nlls), rel_tol=1e-6, abs_tol=1e-5):
|
||||
_append_issue(issues, "schema", f"{label}: val_nll mean disagrees with per-seed runs")
|
||||
return float(acc_mean), float(nll_mean)
|
||||
|
||||
|
||||
def _validate_runs(
|
||||
entry: Dict[str, Any],
|
||||
expected_seeds: List[int],
|
||||
expected_state_bytes: int,
|
||||
label: str,
|
||||
candidate: bool,
|
||||
issues: Dict[str, List[str]],
|
||||
) -> Tuple[List[Dict[str, Any]], int, int]:
|
||||
runs = entry.get("per_seed_runs")
|
||||
if not isinstance(runs, list) or len(runs) != len(expected_seeds):
|
||||
_append_issue(issues, "schema", f"{label}: expected {len(expected_seeds)} per-seed runs")
|
||||
return [], 0, 0
|
||||
|
||||
if [run.get("seed") if isinstance(run, dict) else None for run in runs] != expected_seeds:
|
||||
_append_issue(issues, "config", f"{label}: per-seed run order/content does not match {expected_seeds}")
|
||||
|
||||
total_queries = 0
|
||||
total_samples = 0
|
||||
numeric_fields = (
|
||||
"val_selected_loss",
|
||||
"val_selected_acc",
|
||||
"gbest_loss",
|
||||
"gbest_acc",
|
||||
"wall_time_sec",
|
||||
"optimization_wall_time_sec",
|
||||
"validation_wall_time_sec",
|
||||
"throughput_samples_per_sec",
|
||||
)
|
||||
|
||||
valid_runs: List[Dict[str, Any]] = []
|
||||
for run in runs:
|
||||
if not isinstance(run, dict):
|
||||
_append_issue(issues, "schema", f"{label}: non-object seed record")
|
||||
continue
|
||||
seed_label = f"{label}/seed={run.get('seed')}"
|
||||
for field in numeric_fields:
|
||||
if not _is_finite_number(run.get(field)):
|
||||
_append_issue(issues, "finite", f"{seed_label}: missing/non-finite {field}")
|
||||
|
||||
val_metrics = run.get("val_metrics")
|
||||
if (
|
||||
not isinstance(val_metrics, dict)
|
||||
or not val_metrics
|
||||
or any(not _is_finite_number(value) for value in val_metrics.values())
|
||||
):
|
||||
_append_issue(issues, "finite", f"{seed_label}: missing/non-finite val_metrics")
|
||||
|
||||
if run.get("official_test_evaluations") != 0:
|
||||
_append_issue(issues, "test", f"{seed_label}: official_test_evaluations must be 0")
|
||||
if candidate and run.get("is_finite") is not True:
|
||||
_append_issue(issues, "finite", f"{seed_label}: candidate is_finite must be true")
|
||||
if run.get("total_queries") != EXPECTED_QUERIES:
|
||||
_append_issue(issues, "accounting", f"{seed_label}: total_queries must be {EXPECTED_QUERIES}")
|
||||
if run.get("total_sample_evaluations") != EXPECTED_SAMPLES:
|
||||
_append_issue(issues, "accounting", f"{seed_label}: total_sample_evaluations must be {EXPECTED_SAMPLES}")
|
||||
if run.get("core_swarm_state_bytes") != expected_state_bytes:
|
||||
_append_issue(issues, "state", f"{seed_label}: incorrect core_swarm_state_bytes")
|
||||
|
||||
if isinstance(run.get("total_queries"), int):
|
||||
total_queries += run["total_queries"]
|
||||
if isinstance(run.get("total_sample_evaluations"), int):
|
||||
total_samples += run["total_sample_evaluations"]
|
||||
valid_runs.append(run)
|
||||
|
||||
return valid_runs, total_queries, total_samples
|
||||
|
||||
|
||||
def _validate_artifact(artifact: Dict[str, Any], phase: str) -> Dict[str, Any]:
|
||||
expected_splits, expected_seeds = PHASE_SPECS[phase]
|
||||
issues: Dict[str, List[str]] = {
|
||||
"schema": [],
|
||||
"test": [],
|
||||
"finite": [],
|
||||
"provenance": [],
|
||||
"config": [],
|
||||
"accounting": [],
|
||||
"state": [],
|
||||
}
|
||||
cells: List[Dict[str, Any]] = []
|
||||
state_ratios: Dict[str, float] = {}
|
||||
expected_split_keys = {str(seed) for seed in expected_splits}
|
||||
|
||||
if not isinstance(artifact, dict):
|
||||
_append_issue(issues, "schema", f"{phase}: artifact must be an object")
|
||||
return {
|
||||
"issues": issues,
|
||||
"cells": cells,
|
||||
"state_ratios": state_ratios,
|
||||
"max_state_ratio": 1.0,
|
||||
"policy_signature": None,
|
||||
}
|
||||
|
||||
if artifact.get("phase") != phase:
|
||||
_append_issue(issues, "schema", f"{phase}: phase field mismatch")
|
||||
if artifact.get("split_seeds") != expected_splits:
|
||||
_append_issue(issues, "config", f"{phase}: split_seeds must be {expected_splits}")
|
||||
if artifact.get("swarm_seeds") != expected_seeds:
|
||||
_append_issue(issues, "config", f"{phase}: swarm_seeds must be {expected_seeds}")
|
||||
if artifact.get("official_test_data_loaded") is not False:
|
||||
_append_issue(issues, "test", f"{phase}: official_test_data_loaded must be false")
|
||||
if artifact.get("official_test_evaluations") != 0:
|
||||
_append_issue(issues, "test", f"{phase}: official_test_evaluations must be 0")
|
||||
|
||||
candidate_config = artifact.get("candidate_config")
|
||||
if not isinstance(candidate_config, dict):
|
||||
_append_issue(issues, "schema", f"{phase}: missing candidate_config")
|
||||
candidate_config = {}
|
||||
for field, expected in (
|
||||
("particles", EXPECTED_PARTICLES),
|
||||
("epochs", EXPECTED_EPOCHS),
|
||||
("subset_size", EXPECTED_SUBSET_SIZE),
|
||||
):
|
||||
if candidate_config.get(field) != expected:
|
||||
_append_issue(issues, "config", f"{phase}: candidate_config.{field} must be {expected}")
|
||||
|
||||
workload_config = artifact.get("workloads")
|
||||
if not isinstance(workload_config, dict) or set(workload_config) != set(WORKLOADS):
|
||||
_append_issue(issues, "schema", f"{phase}: workloads metadata must contain exactly {WORKLOADS}")
|
||||
workload_config = {}
|
||||
|
||||
splits = artifact.get("splits")
|
||||
if not isinstance(splits, dict) or set(splits) != expected_split_keys:
|
||||
_append_issue(issues, "schema", f"{phase}: splits must contain exactly {sorted(expected_split_keys)}")
|
||||
splits = splits if isinstance(splits, dict) else {}
|
||||
|
||||
observed_runs = 0
|
||||
observed_queries = 0
|
||||
observed_samples = 0
|
||||
|
||||
for split_seed in expected_splits:
|
||||
split_key = str(split_seed)
|
||||
split_entry = splits.get(split_key)
|
||||
if not isinstance(split_entry, dict):
|
||||
_append_issue(issues, "schema", f"{phase}/{split_key}: missing split object")
|
||||
continue
|
||||
if split_entry.get("split_seed") != split_seed:
|
||||
_append_issue(issues, "provenance", f"{phase}/{split_key}: split_seed mismatch")
|
||||
|
||||
baselines = split_entry.get("baselines")
|
||||
candidates = split_entry.get("candidates")
|
||||
if not isinstance(baselines, dict) or set(baselines) != set(WORKLOADS):
|
||||
_append_issue(issues, "schema", f"{phase}/{split_key}: baseline workloads incomplete")
|
||||
baselines = baselines if isinstance(baselines, dict) else {}
|
||||
if not isinstance(candidates, dict) or set(candidates) != set(WORKLOADS):
|
||||
_append_issue(issues, "schema", f"{phase}/{split_key}: candidate workloads incomplete")
|
||||
candidates = candidates if isinstance(candidates, dict) else {}
|
||||
|
||||
for workload in WORKLOADS:
|
||||
baseline = baselines.get(workload)
|
||||
candidate_entry = candidates.get(workload)
|
||||
label = f"{phase}/{split_key}/{workload}"
|
||||
if not isinstance(baseline, dict) or not isinstance(candidate_entry, dict):
|
||||
_append_issue(issues, "schema", f"{label}: missing baseline or candidate entry")
|
||||
continue
|
||||
|
||||
if baseline.get("method_id") != BASELINE_METHODS[workload]:
|
||||
_append_issue(issues, "config", f"{label}: wrong baseline method")
|
||||
for mode, entry in (("baseline", baseline), ("candidate", candidate_entry)):
|
||||
if entry.get("workload_id") != workload:
|
||||
_append_issue(issues, "schema", f"{label}/{mode}: workload_id mismatch")
|
||||
if entry.get("split_seed") != split_seed:
|
||||
_append_issue(issues, "provenance", f"{label}/{mode}: split_seed mismatch")
|
||||
if entry.get("particles") != EXPECTED_PARTICLES:
|
||||
_append_issue(issues, "config", f"{label}/{mode}: particles mismatch")
|
||||
if entry.get("epochs") != EXPECTED_EPOCHS:
|
||||
_append_issue(issues, "config", f"{label}/{mode}: epochs mismatch")
|
||||
if entry.get("subset_size") != EXPECTED_SUBSET_SIZE:
|
||||
_append_issue(issues, "config", f"{label}/{mode}: subset_size mismatch")
|
||||
if entry.get("seeds") != expected_seeds:
|
||||
_append_issue(issues, "config", f"{label}/{mode}: seeds mismatch")
|
||||
|
||||
fingerprints = (
|
||||
baseline.get("split_fingerprint"),
|
||||
candidate_entry.get("split_fingerprint"),
|
||||
baseline.get("data_fingerprint"),
|
||||
candidate_entry.get("data_fingerprint"),
|
||||
)
|
||||
if any(not isinstance(value, str) or not value for value in fingerprints):
|
||||
_append_issue(issues, "provenance", f"{label}: fingerprints must be non-empty strings")
|
||||
elif fingerprints[0] != fingerprints[1] or fingerprints[2] != fingerprints[3]:
|
||||
_append_issue(issues, "provenance", f"{label}: baseline/candidate fingerprints differ")
|
||||
|
||||
total_dim = TOTAL_DIMS[workload]
|
||||
baseline_states = 5 * EXPECTED_PARTICLES + (1 if BASELINE_METHODS[workload] == "G8" else 0)
|
||||
expected_baseline_bytes = baseline_states * total_dim * 4
|
||||
raw_ratio = candidate_entry.get("ratio")
|
||||
if not _is_finite_number(raw_ratio) or not (0.0 < float(raw_ratio) <= 1.0):
|
||||
_append_issue(issues, "state", f"{label}: invalid candidate ratio")
|
||||
expected_candidate_bytes = -1
|
||||
else:
|
||||
expected_latent_dim = compute_latent_dim(total_dim, float(raw_ratio))
|
||||
expected_candidate_bytes = compute_core_swarm_state_bytes(EXPECTED_PARTICLES, expected_latent_dim)
|
||||
if candidate_entry.get("total_dim") != total_dim:
|
||||
_append_issue(issues, "state", f"{label}: total_dim mismatch")
|
||||
if candidate_entry.get("latent_dim") != expected_latent_dim:
|
||||
_append_issue(issues, "state", f"{label}: latent_dim mismatch")
|
||||
if candidate_entry.get("core_swarm_state_bytes") != expected_candidate_bytes:
|
||||
_append_issue(issues, "state", f"{label}: candidate state bytes mismatch")
|
||||
if candidate_entry.get("baseline_core_swarm_state_bytes") != expected_baseline_bytes:
|
||||
_append_issue(issues, "state", f"{label}: candidate baseline state bytes mismatch")
|
||||
ratio = expected_candidate_bytes / expected_baseline_bytes
|
||||
state_ratios[workload] = max(state_ratios.get(workload, 0.0), ratio)
|
||||
if not _is_finite_number(candidate_entry.get("state_ratio")) or not math.isclose(
|
||||
float(candidate_entry.get("state_ratio", -1.0)), ratio, rel_tol=1e-6, abs_tol=1e-6
|
||||
):
|
||||
_append_issue(issues, "state", f"{label}: reported state_ratio mismatch")
|
||||
|
||||
baseline_runs, b_queries, b_samples = _validate_runs(
|
||||
baseline,
|
||||
expected_seeds,
|
||||
expected_baseline_bytes,
|
||||
f"{label}/baseline",
|
||||
False,
|
||||
issues,
|
||||
)
|
||||
candidate_runs, c_queries, c_samples = _validate_runs(
|
||||
candidate_entry,
|
||||
expected_seeds,
|
||||
expected_candidate_bytes,
|
||||
f"{label}/candidate",
|
||||
True,
|
||||
issues,
|
||||
)
|
||||
observed_runs += len(baseline_runs) + len(candidate_runs)
|
||||
observed_queries += b_queries + c_queries
|
||||
observed_samples += b_samples + c_samples
|
||||
|
||||
baseline_stats = _validate_stats(baseline, baseline_runs, f"{label}/baseline", issues)
|
||||
candidate_stats = _validate_stats(candidate_entry, candidate_runs, f"{label}/candidate", issues)
|
||||
if baseline_stats is not None and candidate_stats is not None:
|
||||
baseline_acc, baseline_nll = baseline_stats
|
||||
candidate_acc, candidate_nll = candidate_stats
|
||||
nll_reduction = (
|
||||
(baseline_nll - candidate_nll) / baseline_nll
|
||||
if baseline_nll > 0.0
|
||||
else float("nan")
|
||||
)
|
||||
if not math.isfinite(nll_reduction):
|
||||
_append_issue(issues, "finite", f"{label}: NLL reduction is non-finite")
|
||||
else:
|
||||
cells.append(
|
||||
{
|
||||
"phase": phase,
|
||||
"split_seed": split_seed,
|
||||
"workload_id": workload,
|
||||
"baseline_acc": baseline_acc,
|
||||
"candidate_acc": candidate_acc,
|
||||
"baseline_nll": baseline_nll,
|
||||
"candidate_nll": candidate_nll,
|
||||
"acc_gain_pp": candidate_acc - baseline_acc,
|
||||
"nll_reduction_fraction": nll_reduction,
|
||||
}
|
||||
)
|
||||
|
||||
expected_runs = len(expected_splits) * len(WORKLOADS) * len(expected_seeds) * 2
|
||||
if observed_runs != expected_runs:
|
||||
_append_issue(issues, "accounting", f"{phase}: observed {observed_runs} runs, expected {expected_runs}")
|
||||
resources = artifact.get("resource_totals")
|
||||
if not isinstance(resources, dict):
|
||||
_append_issue(issues, "accounting", f"{phase}: missing resource_totals")
|
||||
resources = {}
|
||||
if resources.get("total_runs") != observed_runs:
|
||||
_append_issue(issues, "accounting", f"{phase}: total_runs does not match records")
|
||||
if resources.get("total_queries") != observed_queries:
|
||||
_append_issue(issues, "accounting", f"{phase}: total_queries does not match records")
|
||||
if resources.get("total_samples_evaluated") != observed_samples:
|
||||
_append_issue(issues, "accounting", f"{phase}: total_samples_evaluated does not match records")
|
||||
if resources.get("official_test_evaluations") != 0:
|
||||
_append_issue(issues, "test", f"{phase}: resource official_test_evaluations must be 0")
|
||||
|
||||
max_state_ratio = max(state_ratios.values(), default=1.0)
|
||||
policy_signature = {
|
||||
"candidate_config": candidate_config,
|
||||
"workloads": workload_config,
|
||||
}
|
||||
return {
|
||||
"issues": issues,
|
||||
"cells": cells,
|
||||
"state_ratios": state_ratios,
|
||||
"max_state_ratio": max_state_ratio,
|
||||
"policy_signature": policy_signature,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_heavy_cross_split(
|
||||
development_artifact: Dict[str, Any],
|
||||
confirmation_artifact: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
dev_result = _validate_artifact(development_artifact, "development")
|
||||
conf_result = (
|
||||
_validate_artifact(confirmation_artifact, "confirmation")
|
||||
if confirmation_artifact is not None
|
||||
else None
|
||||
)
|
||||
|
||||
gates: Dict[str, Dict[str, Any]] = {}
|
||||
failed_gates: List[str] = []
|
||||
development_gate_names: List[str] = []
|
||||
|
||||
def record_gate(
|
||||
name: str,
|
||||
passed: bool,
|
||||
observed: Any,
|
||||
expected: Any,
|
||||
details: str,
|
||||
development_gate: bool = False,
|
||||
) -> None:
|
||||
gates[name] = {
|
||||
"pass": bool(passed),
|
||||
"observed": observed,
|
||||
"expected": expected,
|
||||
"details": details,
|
||||
}
|
||||
if not passed:
|
||||
failed_gates.append(name)
|
||||
if development_gate:
|
||||
development_gate_names.append(name)
|
||||
|
||||
all_results = [dev_result] + ([conf_result] if conf_result is not None else [])
|
||||
record_gate(
|
||||
"schema_and_phase_seeds",
|
||||
all(not result["issues"]["schema"] for result in all_results),
|
||||
[message for result in all_results for message in result["issues"]["schema"]][:10],
|
||||
"complete artifacts with exact declared phase/split/swarm seeds",
|
||||
"Missing evidence is rejected rather than defaulted",
|
||||
True,
|
||||
)
|
||||
record_gate(
|
||||
"official_test_sealed",
|
||||
all(not result["issues"]["test"] for result in all_results),
|
||||
[message for result in all_results for message in result["issues"]["test"]][:10],
|
||||
"loaded=false and evaluations=0 at artifact, resource, and run levels",
|
||||
"Official test data must never be loaded or evaluated",
|
||||
True,
|
||||
)
|
||||
record_gate(
|
||||
"all_runs_finite",
|
||||
all(not result["issues"]["finite"] for result in all_results),
|
||||
[message for result in all_results for message in result["issues"]["finite"]][:10],
|
||||
"all aggregate and per-run validation metrics and times finite",
|
||||
"Every recorded validation metric must be finite",
|
||||
True,
|
||||
)
|
||||
record_gate(
|
||||
"split_and_fingerprint_matched",
|
||||
all(not result["issues"]["provenance"] for result in all_results),
|
||||
[message for result in all_results for message in result["issues"]["provenance"]][:10],
|
||||
"non-empty matching split/data fingerprints and split seeds per baseline/candidate cell",
|
||||
"Every delta must use a baseline rerun on the identical partition",
|
||||
True,
|
||||
)
|
||||
|
||||
policy_matches = conf_result is None or (
|
||||
dev_result["policy_signature"] == conf_result["policy_signature"]
|
||||
)
|
||||
config_issues = [message for result in all_results for message in result["issues"]["config"]]
|
||||
if not policy_matches:
|
||||
config_issues.append("confirmation candidate policy differs from the frozen development policy")
|
||||
record_gate(
|
||||
"configuration_and_policy_matched",
|
||||
not config_issues,
|
||||
config_issues[:10],
|
||||
"12p x 80e x fixed10k, exact seeds, matching baseline, identical frozen candidate policy",
|
||||
"Confirmation cannot change the development-selected policy",
|
||||
True,
|
||||
)
|
||||
record_gate(
|
||||
"query_and_sample_accounting_exact",
|
||||
all(not result["issues"]["accounting"] for result in all_results),
|
||||
[message for result in all_results for message in result["issues"]["accounting"]][:10],
|
||||
f"{EXPECTED_QUERIES} queries and {EXPECTED_SAMPLES} sample evaluations per run",
|
||||
"Per-run and aggregate accounting must agree exactly",
|
||||
True,
|
||||
)
|
||||
|
||||
max_state_ratio = max(result["max_state_ratio"] for result in all_results)
|
||||
state_issues = [message for result in all_results for message in result["issues"]["state"]]
|
||||
state_ok = not state_issues and 0.0 < max_state_ratio <= 0.5 + 1e-12
|
||||
record_gate(
|
||||
"maximum_state_ratio_each_workload",
|
||||
state_ok,
|
||||
{"max_state_ratio": max_state_ratio, "issues": state_issues[:10]},
|
||||
"analytically verified state ratio <= 0.5 for every cell",
|
||||
"No absent or reported-only state evidence is accepted",
|
||||
True,
|
||||
)
|
||||
|
||||
dev_cells = dev_result["cells"]
|
||||
conf_cells = conf_result["cells"] if conf_result is not None else []
|
||||
all_cells = dev_cells + conf_cells
|
||||
expected_dev_cells = len(EXPECTED_DEV_SPLIT_SEEDS) * len(WORKLOADS)
|
||||
complete_dev_cells = len(dev_cells) == expected_dev_cells
|
||||
|
||||
def nonregression(cells: List[Dict[str, Any]]) -> Tuple[bool, float, float]:
|
||||
if not cells:
|
||||
return False, float("inf"), float("inf")
|
||||
max_acc_regression = max(-cell["acc_gain_pp"] for cell in cells)
|
||||
max_nll_regression = max(-cell["nll_reduction_fraction"] for cell in cells)
|
||||
return (
|
||||
max_acc_regression <= 1.0 + 1e-12 and max_nll_regression <= 0.05 + 1e-12,
|
||||
max_acc_regression,
|
||||
max_nll_regression,
|
||||
)
|
||||
|
||||
all_nonreg, max_acc_reg, max_nll_reg = nonregression(all_cells)
|
||||
record_gate(
|
||||
"maximum_accuracy_regression_percentage_points_each_split_workload",
|
||||
bool(all_cells) and max_acc_reg <= 1.0 + 1e-12,
|
||||
max_acc_reg,
|
||||
"<= 1.0 pp",
|
||||
"No evaluated split-workload cell may regress accuracy by more than 1 pp",
|
||||
True,
|
||||
)
|
||||
record_gate(
|
||||
"maximum_nll_regression_fraction_each_split_workload",
|
||||
bool(all_cells) and max_nll_reg <= 0.05 + 1e-12,
|
||||
max_nll_reg,
|
||||
"<= 0.05",
|
||||
"No evaluated split-workload cell may regress NLL by more than 5%",
|
||||
True,
|
||||
)
|
||||
dev_nonreg, dev_acc_reg, dev_nll_reg = nonregression(dev_cells)
|
||||
|
||||
dev_acc_mean = _mean([cell["acc_gain_pp"] for cell in dev_cells]) if dev_cells else float("-inf")
|
||||
dev_nll_mean = _mean([cell["nll_reduction_fraction"] for cell in dev_cells]) if dev_cells else float("-inf")
|
||||
record_gate(
|
||||
"development_grand_mean_accuracy_gain_minimum_pp",
|
||||
complete_dev_cells and dev_acc_mean >= 0.0,
|
||||
dev_acc_mean,
|
||||
">= 0.0 pp",
|
||||
"Development grand mean accuracy must not regress",
|
||||
True,
|
||||
)
|
||||
record_gate(
|
||||
"development_grand_mean_nll_reduction_minimum_fraction",
|
||||
complete_dev_cells and dev_nll_mean >= 0.0,
|
||||
dev_nll_mean,
|
||||
">= 0.0",
|
||||
"Development grand mean NLL must not regress",
|
||||
True,
|
||||
)
|
||||
|
||||
dev_mw = [cell for cell in dev_cells if cell["workload_id"] == "mnist_wide"]
|
||||
dev_mw_acc = _mean([cell["acc_gain_pp"] for cell in dev_mw]) if dev_mw else float("-inf")
|
||||
dev_mw_nll = _mean([cell["nll_reduction_fraction"] for cell in dev_mw]) if dev_mw else float("-inf")
|
||||
record_gate(
|
||||
"development_mnist_wide_improvement",
|
||||
len(dev_mw) == len(EXPECTED_DEV_SPLIT_SEEDS) and (dev_mw_acc >= 2.0 or dev_mw_nll >= 0.05),
|
||||
{"accuracy_gain_pp": dev_mw_acc, "nll_reduction_fraction": dev_mw_nll},
|
||||
"mean accuracy gain >=2pp OR mean NLL reduction >=5%",
|
||||
"The prior worst baseline workload must materially improve across development partitions",
|
||||
True,
|
||||
)
|
||||
|
||||
development_pass = (
|
||||
all(not messages for messages in dev_result["issues"].values())
|
||||
and 0.0 < dev_result["max_state_ratio"] <= 0.5 + 1e-12
|
||||
and complete_dev_cells
|
||||
and dev_nonreg
|
||||
and dev_acc_mean >= 0.0
|
||||
and dev_nll_mean >= 0.0
|
||||
and len(dev_mw) == len(EXPECTED_DEV_SPLIT_SEEDS)
|
||||
and (dev_mw_acc >= 2.0 or dev_mw_nll >= 0.05)
|
||||
)
|
||||
if confirmation_artifact is None:
|
||||
record_gate(
|
||||
"confirmation_executed",
|
||||
False,
|
||||
"not executed",
|
||||
"one exact confirmation artifact after development_pass",
|
||||
"A development pass only qualifies the frozen policy for one-shot confirmation",
|
||||
)
|
||||
else:
|
||||
record_gate(
|
||||
"confirmation_executed",
|
||||
True,
|
||||
{"split_seeds": confirmation_artifact.get("split_seeds"), "swarm_seeds": confirmation_artifact.get("swarm_seeds")},
|
||||
{"split_seeds": EXPECTED_CONF_SPLIT_SEEDS, "swarm_seeds": EXPECTED_CONF_SWARM_SEEDS},
|
||||
"Confirmation evidence is evaluated only with the exact sealed phase contract",
|
||||
)
|
||||
|
||||
expected_conf_cells = len(EXPECTED_CONF_SPLIT_SEEDS) * len(WORKLOADS)
|
||||
complete_conf_cells = len(conf_cells) == expected_conf_cells
|
||||
conf_nonreg, conf_acc_reg, conf_nll_reg = nonregression(conf_cells)
|
||||
record_gate(
|
||||
"confirmation_per_cell_non_regression",
|
||||
complete_conf_cells and conf_nonreg,
|
||||
{"cells": len(conf_cells), "max_acc_regression_pp": conf_acc_reg, "max_nll_regression_fraction": conf_nll_reg},
|
||||
"4 cells; accuracy regression <=1pp and NLL regression <=5% in each",
|
||||
"The sealed partition must remain safe workload by workload",
|
||||
)
|
||||
|
||||
conf_acc_mean = _mean([cell["acc_gain_pp"] for cell in conf_cells]) if conf_cells else float("-inf")
|
||||
conf_nll_mean = _mean([cell["nll_reduction_fraction"] for cell in conf_cells]) if conf_cells else float("-inf")
|
||||
record_gate(
|
||||
"confirmation_grand_mean_accuracy_gain_minimum_pp",
|
||||
complete_conf_cells and conf_acc_mean >= 1.5,
|
||||
conf_acc_mean,
|
||||
">= 1.5 pp",
|
||||
"One-shot confirmation must retain the predeclared accuracy effect",
|
||||
)
|
||||
record_gate(
|
||||
"confirmation_grand_mean_nll_reduction_minimum_fraction",
|
||||
complete_conf_cells and conf_nll_mean >= 0.02,
|
||||
conf_nll_mean,
|
||||
">= 0.02",
|
||||
"One-shot confirmation must retain the predeclared NLL effect",
|
||||
)
|
||||
|
||||
conf_mw = [cell for cell in conf_cells if cell["workload_id"] == "mnist_wide"]
|
||||
conf_mw_acc = _mean([cell["acc_gain_pp"] for cell in conf_mw]) if conf_mw else float("-inf")
|
||||
conf_mw_nll = _mean([cell["nll_reduction_fraction"] for cell in conf_mw]) if conf_mw else float("-inf")
|
||||
record_gate(
|
||||
"confirmation_mnist_wide_improvement",
|
||||
len(conf_mw) == 1 and (conf_mw_acc >= 1.0 or conf_mw_nll >= 0.03),
|
||||
{"accuracy_gain_pp": conf_mw_acc, "nll_reduction_fraction": conf_mw_nll},
|
||||
"accuracy gain >=1pp OR NLL reduction >=3%",
|
||||
"The prior worst workload must improve on the sealed partition",
|
||||
)
|
||||
|
||||
combined_mw = dev_mw + conf_mw
|
||||
combined_mw_acc = _mean([cell["acc_gain_pp"] for cell in combined_mw]) if combined_mw else float("-inf")
|
||||
combined_mw_nll = _mean([cell["nll_reduction_fraction"] for cell in combined_mw]) if combined_mw else float("-inf")
|
||||
record_gate(
|
||||
"combined_mnist_wide_improvement",
|
||||
len(combined_mw) == 3 and (combined_mw_acc >= 2.0 or combined_mw_nll >= 0.05),
|
||||
{"accuracy_gain_pp": combined_mw_acc, "nll_reduction_fraction": combined_mw_nll},
|
||||
"three-split mean accuracy gain >=2pp OR NLL reduction >=5%",
|
||||
"The material worst-workload improvement must hold across all new partitions",
|
||||
)
|
||||
|
||||
score_cells = all_cells if confirmation_artifact is not None else dev_cells
|
||||
mean_acc_gain = _mean([cell["acc_gain_pp"] for cell in score_cells]) if score_cells else 0.0
|
||||
mean_nll_reduction = _mean([cell["nll_reduction_fraction"] for cell in score_cells]) if score_cells else 0.0
|
||||
score_gate_names = list(gates) if confirmation_artifact is not None else development_gate_names
|
||||
score_failed_gates = sum(not gates[name]["pass"] for name in score_gate_names)
|
||||
state_points = 10.0 * math.log2(1.0 / max_state_ratio) if 0.0 < max_state_ratio <= 1.0 else 0.0
|
||||
score = (
|
||||
100.0 * mean_nll_reduction
|
||||
+ mean_acc_gain
|
||||
+ state_points
|
||||
- 100.0 * score_failed_gates
|
||||
)
|
||||
mission_pass = confirmation_artifact is not None and not failed_gates
|
||||
|
||||
return {
|
||||
"pass": bool(mission_pass),
|
||||
"development_pass": bool(development_pass),
|
||||
"eligible_for_confirmation": bool(development_pass and confirmation_artifact is None),
|
||||
"score": float(score),
|
||||
"evaluator_version": EVALUATOR_VERSION,
|
||||
"failed_hard_gate_count": len(failed_gates),
|
||||
"failed_gates": failed_gates,
|
||||
"score_failed_gate_count": score_failed_gates,
|
||||
"gates": gates,
|
||||
"score_components": {
|
||||
"mean_relative_nll_reduction_pct": 100.0 * mean_nll_reduction,
|
||||
"mean_accuracy_gain_pp": mean_acc_gain,
|
||||
"state_efficiency_points": state_points,
|
||||
"gate_penalty_points": 100.0 * score_failed_gates,
|
||||
"max_state_ratio": max_state_ratio,
|
||||
},
|
||||
"summary_metrics": {
|
||||
"development_cells": len(dev_cells),
|
||||
"confirmation_cells": len(conf_cells),
|
||||
"development_grand_mean_accuracy_gain_pp": dev_acc_mean,
|
||||
"development_grand_mean_nll_reduction_fraction": dev_nll_mean,
|
||||
"development_mnist_wide_accuracy_gain_pp": dev_mw_acc,
|
||||
"development_mnist_wide_nll_reduction_fraction": dev_mw_nll,
|
||||
},
|
||||
"cell_metrics": all_cells,
|
||||
"state_ratios": {
|
||||
"development": dev_result["state_ratios"],
|
||||
"confirmation": conf_result["state_ratios"] if conf_result is not None else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Strict Heavy PSO cross-split evaluator")
|
||||
parser.add_argument("--development", required=True, help="Development artifact JSON")
|
||||
parser.add_argument("--confirmation", default=None, help="Optional one-shot confirmation artifact JSON")
|
||||
parser.add_argument("--output", default=None, help="Optional evaluation JSON output")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_parser().parse_args()
|
||||
development = load_artifact(Path(args.development))
|
||||
confirmation = load_artifact(Path(args.confirmation)) if args.confirmation else None
|
||||
result = evaluate_heavy_cross_split(development, confirmation)
|
||||
if args.output:
|
||||
save_json_atomic(result, Path(args.output))
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,974 @@
|
||||
"""Independent evaluator for the post-training model-convergence protocol.
|
||||
|
||||
This module is deliberately data-only: it reads JSON manifests, result records and
|
||||
stored prediction records. It never imports an adapter, optional detection
|
||||
package, dataset, checkpoint, or model. A result is useful only when all of the
|
||||
frozen matrix and sealing invariants can be demonstrated from the saved evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
EVALUATOR_VERSION = "POST-TRAINING-MODEL-CONVERGENCE-EVALUATOR 1.0.0"
|
||||
PROTOCOL_VERSION = "post-training-model-convergence-1.0.0"
|
||||
WORKLOADS = ("cifar10_resnet18", "cifar10_resnet50", "voc_yolo11n")
|
||||
CLASSIFICATION_WORKLOADS = WORKLOADS[:2]
|
||||
DETECTION_WORKLOAD = WORKLOADS[2]
|
||||
BASE_SEEDS = (501, 502, 503)
|
||||
SWARM_SEEDS = (601, 602, 603)
|
||||
SPLIT_SEED = 20260908
|
||||
PROJECTION_SEED = 20260909
|
||||
BOOTSTRAP_SEED = 20260910
|
||||
PARTICLES = 12
|
||||
PRIMARY_GENERATIONS = 60
|
||||
ENSEMBLE_GENERATIONS = 20
|
||||
PRIMARY_QUERIES = PARTICLES * PRIMARY_GENERATIONS
|
||||
ENSEMBLE_QUERIES = PARTICLES * ENSEMBLE_GENERATIONS
|
||||
PRIMARY_RUNS = len(WORKLOADS) * len(BASE_SEEDS) * len(SWARM_SEEDS)
|
||||
ENSEMBLE_RUNS = len(WORKLOADS) * len(SWARM_SEEDS)
|
||||
TOTAL_PSO_QUERIES = PRIMARY_RUNS * PRIMARY_QUERIES + ENSEMBLE_RUNS * ENSEMBLE_QUERIES
|
||||
OBJECTIVE_SAMPLES = {"cifar10_resnet18": 1024, "cifar10_resnet50": 1024, "voc_yolo11n": 512}
|
||||
TOTAL_CANDIDATE_SAMPLES = sum(
|
||||
(len(BASE_SEEDS) * len(SWARM_SEEDS) * PRIMARY_QUERIES + len(SWARM_SEEDS) * ENSEMBLE_QUERIES)
|
||||
* OBJECTIVE_SAMPLES[w] for w in WORKLOADS
|
||||
)
|
||||
BOOTSTRAP_RESAMPLES = 2000
|
||||
BOOTSTRAP_ALPHA = 0.05 / 6.0
|
||||
|
||||
ISSUE_CATEGORIES = (
|
||||
"schema", "provenance", "matrix", "accounting", "seal", "selection",
|
||||
"finite", "metrics", "plateau", "overfit", "leakage", "bootstrap", "gates",
|
||||
)
|
||||
|
||||
|
||||
def _issue(issues: dict[str, list[str]], category: str, message: str) -> None:
|
||||
issues.setdefault(category, []).append(message)
|
||||
|
||||
|
||||
def _finite(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
|
||||
|
||||
|
||||
def _walk_nonfinite(value: Any, path: str = "") -> list[str]:
|
||||
out: list[str] = []
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
out.append(path or "$")
|
||||
elif isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
out.extend(_walk_nonfinite(item, f"{path}.{key}" if path else str(key)))
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for i, item in enumerate(value):
|
||||
out.extend(_walk_nonfinite(item, f"{path}[{i}]"))
|
||||
return out
|
||||
|
||||
|
||||
def _json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _atomic_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _number(value: Any, *keys: str) -> float | None:
|
||||
if isinstance(value, Mapping):
|
||||
for key in keys:
|
||||
candidate = value.get(key)
|
||||
if _finite(candidate):
|
||||
return float(candidate)
|
||||
for candidate in value.values():
|
||||
found = _number(candidate, *keys)
|
||||
if found is not None:
|
||||
return found
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for candidate in value:
|
||||
found = _number(candidate, *keys)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _int(value: Any, *keys: str) -> int | None:
|
||||
number = _number(value, *keys)
|
||||
if number is None or not number.is_integer():
|
||||
return None
|
||||
return int(number)
|
||||
|
||||
|
||||
def _same(a: Any, b: Any, tol: float = 1e-9) -> bool:
|
||||
return _finite(a) and _finite(b) and math.isclose(float(a), float(b), rel_tol=tol, abs_tol=tol)
|
||||
|
||||
|
||||
def _resolve_json(root: Path, value: Any) -> Any:
|
||||
"""Resolve a saved JSON prediction reference without opening model/data files."""
|
||||
if isinstance(value, Mapping):
|
||||
for key in ("path", "file", "artifact", "prediction_artifact", "predictions_path"):
|
||||
ref = value.get(key)
|
||||
if isinstance(ref, str) and ref.lower().endswith((".json", ".jsonl", ".pt")):
|
||||
return _resolve_json(root, ref)
|
||||
return value
|
||||
if isinstance(value, str) and value.lower().endswith((".json", ".jsonl", ".pt")):
|
||||
path = (root / value).resolve()
|
||||
if root.resolve() not in path.parents:
|
||||
raise ValueError(f"prediction artifact escapes run root: {value}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(path)
|
||||
if path.suffix == ".jsonl":
|
||||
return [_json_line for _json_line in (json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()) if _json_line]
|
||||
if path.suffix == ".pt":
|
||||
try:
|
||||
import torch
|
||||
return torch.load(path, map_location="cpu", weights_only=True)
|
||||
except (ImportError, OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
raise ValueError(f"unable to load weights-only prediction artifact: {path}: {exc}") from exc
|
||||
return _json(path)
|
||||
return value
|
||||
|
||||
|
||||
def _hash_manifest(root: Path, manifest: Mapping[str, Any], issues: dict[str, list[str]]) -> bool:
|
||||
good = True
|
||||
if manifest.get("protocol_version") != PROTOCOL_VERSION:
|
||||
_issue(issues, "provenance", f"frozen manifest protocol mismatch: {manifest.get('protocol_version')!r}")
|
||||
good = False
|
||||
if manifest.get("state") != "frozen":
|
||||
_issue(issues, "seal", f"frozen manifest state must be 'frozen', got {manifest.get('state')!r}")
|
||||
good = False
|
||||
declared = manifest.get("manifest_hash")
|
||||
payload = {key: manifest[key] for key in ("protocol_version", "config", "artifacts", "state") if key in manifest}
|
||||
if not isinstance(declared, str) or hashlib.sha256(_canonical(payload)).hexdigest() != declared:
|
||||
_issue(issues, "seal", "frozen_manifest.json self-hash mismatch")
|
||||
good = False
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, Mapping) or not artifacts:
|
||||
_issue(issues, "schema", "frozen manifest requires a non-empty artifacts map")
|
||||
return False
|
||||
for name, expected in artifacts.items():
|
||||
if not isinstance(name, str) or not isinstance(expected, str) or len(expected) != 64:
|
||||
_issue(issues, "schema", f"invalid frozen artifact hash declaration: {name!r}")
|
||||
good = False
|
||||
continue
|
||||
path = (root / name).resolve()
|
||||
if root.resolve() not in path.parents:
|
||||
_issue(issues, "seal", f"frozen artifact escapes run root: {name}")
|
||||
good = False
|
||||
elif not path.is_file():
|
||||
_issue(issues, "seal", f"frozen artifact is missing: {name}")
|
||||
good = False
|
||||
elif _sha256(path) != expected:
|
||||
_issue(issues, "seal", f"frozen artifact hash drift: {name}")
|
||||
good = False
|
||||
return good
|
||||
|
||||
|
||||
def _config_checks(config: Any, issues: dict[str, list[str]], workload_id: str | None = None) -> None:
|
||||
if not isinstance(config, Mapping):
|
||||
_issue(issues, "schema", "missing or non-object config")
|
||||
return
|
||||
expected: dict[str, Any] = {
|
||||
"protocol_version": PROTOCOL_VERSION, "split_seed": SPLIT_SEED,
|
||||
"projection_seed": PROJECTION_SEED, "bootstrap_seed": BOOTSTRAP_SEED,
|
||||
"particle_count": PARTICLES, "pso_generations": PRIMARY_GENERATIONS,
|
||||
"residual_dimension": 64, "residual_bound": 1.0, "initial_radius": 0.25,
|
||||
"objective_checkpoints": [0, 10, 20, 30, 40, 50, 60],
|
||||
"base_seeds": list(BASE_SEEDS), "swarm_seeds": list(SWARM_SEEDS),
|
||||
}
|
||||
for key, value in expected.items():
|
||||
got = config.get(key)
|
||||
if got != value and not (isinstance(got, tuple) and list(got) == value):
|
||||
_issue(issues, "provenance", f"config.{key} must be {value!r}, got {got!r}")
|
||||
ids = config.get("workload_ids")
|
||||
if ids is not None and sorted(ids) != sorted(WORKLOADS):
|
||||
_issue(issues, "matrix", f"config.workload_ids must be {list(WORKLOADS)!r}")
|
||||
if workload_id and config.get("workload_id") not in (None, workload_id):
|
||||
_issue(issues, "matrix", f"config.workload_id disagrees with {workload_id}")
|
||||
|
||||
|
||||
def _counter(value: Any, keys: Sequence[str]) -> int | None:
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
for key in keys:
|
||||
candidate = value.get(key)
|
||||
if isinstance(candidate, int) and not isinstance(candidate, bool):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _check_leakage(result: Mapping[str, Any], issues: dict[str, list[str]], workload: str) -> None:
|
||||
leakage = result.get("leakage_counters")
|
||||
if not isinstance(leakage, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: missing leakage_counters")
|
||||
leakage = {}
|
||||
loaded_before = leakage.get("official_test_data_loaded_before_freeze", leakage.get("test_data_loaded_before_freeze"))
|
||||
if loaded_before is not False:
|
||||
_issue(issues, "leakage", f"{workload}: official test data must be explicitly marked not loaded before freeze")
|
||||
evaluated_before = leakage.get("official_test_evaluations_before_freeze", leakage.get("test_evaluations_before_freeze", leakage.get("official_test_forward_passes_before_freeze")))
|
||||
if evaluated_before != 0:
|
||||
_issue(issues, "leakage", f"{workload}: official test exposure before freeze must be explicitly zero")
|
||||
for key in ("official_test_data_loaded_before_freeze", "official_test_evaluations_before_freeze"):
|
||||
if key in result and ((key.endswith("freeze") and result[key] not in (False, 0))):
|
||||
_issue(issues, "leakage", f"{workload}: contradictory top-level {key}")
|
||||
construction = _counter(leakage, ("official_test_construction", "official_test_dataset_construction"))
|
||||
if construction != 1:
|
||||
_issue(issues, "leakage", f"{workload}: official test construction must equal one, got {construction}")
|
||||
forwards = _counter(leakage, ("official_test_forward_passes", "official_test_evaluations"))
|
||||
if forwards is None or forwards < 1:
|
||||
_issue(issues, "leakage", f"{workload}: official test forward/evaluation ledger must be positive, got {forwards}")
|
||||
confirmation = result.get("confirmation")
|
||||
if not isinstance(confirmation, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: missing confirmation record")
|
||||
return
|
||||
for key in ("second_confirmation", "confirmation_repeated", "post_test_tuning", "post_test_reruns"):
|
||||
if confirmation.get(key) not in (None, False, 0, []):
|
||||
_issue(issues, "leakage", f"{workload}: forbidden repeated confirmation/tuning flag {key}")
|
||||
for key in ("official_test_data_loaded_before_freeze", "official_test_evaluations_before_freeze"):
|
||||
if key in confirmation and confirmation[key] not in (False, 0):
|
||||
_issue(issues, "leakage", f"{workload}: confirmation contradicts sealed pre-freeze {key}")
|
||||
|
||||
|
||||
def _record_queries(record: Mapping[str, Any]) -> tuple[int | None, int | None]:
|
||||
counters = record.get("counters") if isinstance(record.get("counters"), Mapping) else record
|
||||
queries = _int(counters, "objective_queries", "queries", "query_count", "total_queries", "evaluated_queries")
|
||||
samples = _int(counters, "objective_samples", "samples", "sample_evaluations", "candidate_sample_evaluations", "total_sample_evaluations")
|
||||
return queries, samples
|
||||
|
||||
|
||||
def _seed_from_key(key: Any) -> int | None:
|
||||
try:
|
||||
text = str(key)
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_cells(node: Any, base: int | None = None, swarm: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Collect cells from either explicit records or base->swarm mappings."""
|
||||
found: list[dict[str, Any]] = []
|
||||
if isinstance(node, Mapping):
|
||||
b = _int(node, "base_seed") if _int(node, "base_seed") is not None else base
|
||||
s = _int(node, "swarm_seed") if _int(node, "swarm_seed") is not None else swarm
|
||||
if s is None:
|
||||
s = _int(node, "seed")
|
||||
if b is not None and s is not None and any(k in node for k in ("counters", "objective_queries", "objective_samples", "queries", "samples", "generation", "endpoint", "metrics")):
|
||||
item = dict(node); item.setdefault("base_seed", b); item.setdefault("swarm_seed", s); found.append(item)
|
||||
for key, value in node.items():
|
||||
key_seed = _seed_from_key(key)
|
||||
if key_seed in BASE_SEEDS:
|
||||
found.extend(_collect_cells(value, key_seed, s))
|
||||
elif key_seed in SWARM_SEEDS:
|
||||
found.extend(_collect_cells(value, b, key_seed))
|
||||
elif key not in {"base_seed", "swarm_seed"}:
|
||||
found.extend(_collect_cells(value, b, s))
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
found.extend(_collect_cells(value, base, swarm))
|
||||
return found
|
||||
|
||||
|
||||
def _collect_base_cells(node: Any, base: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Collect one record per base seed from flattened or base->record schemas."""
|
||||
found: list[dict[str, Any]] = []
|
||||
if isinstance(node, Mapping):
|
||||
b = _int(node, "base_seed") if _int(node, "base_seed") is not None else base
|
||||
if b is not None and any(k in node for k in ("updates", "gradient_updates", "counters", "metrics", "checkpoint", "endpoint", "objective")):
|
||||
item = dict(node); item.setdefault("base_seed", b); found.append(item)
|
||||
for key, value in node.items():
|
||||
key_seed = _seed_from_key(key)
|
||||
if key_seed in BASE_SEEDS: found.extend(_collect_base_cells(value, key_seed))
|
||||
elif key not in {"base_seed", "swarm_seed"}: found.extend(_collect_base_cells(value, b))
|
||||
elif isinstance(node, list):
|
||||
for value in node: found.extend(_collect_base_cells(value, base))
|
||||
return found
|
||||
|
||||
|
||||
def _method_base_cells(result: Mapping[str, Any], method: str) -> list[dict[str, Any]]:
|
||||
arms = result.get("arms")
|
||||
return _collect_base_cells(arms.get(method)) if isinstance(arms, Mapping) and method in arms else []
|
||||
|
||||
|
||||
def _method_evidence(value: Any, method: str, family: str, root: Path) -> bool:
|
||||
"""Require non-empty metric or prediction evidence below an exact method key."""
|
||||
if isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
normalized = str(key).lower().replace("-", "_")
|
||||
if normalized == method:
|
||||
if _prediction_records(item, root) is not None: return True
|
||||
if isinstance(item, Mapping) and (_number(item, "nll", "loss", "accuracy", "map50_95", "map50", "mAP50-95") is not None): return True
|
||||
if _method_evidence(item, method, family, root): return True
|
||||
if _method_evidence(item, method, family, root): return True
|
||||
elif isinstance(value, list):
|
||||
return bool(value) and any(_method_evidence(item, method, family, root) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _require_confirmation_methods(result: Mapping[str, Any], workload: str, family: str, root: Path, issues: dict[str, list[str]]) -> None:
|
||||
confirmation = result.get("confirmation")
|
||||
required = {"feature_pso", "feature_random", "feature_adam", "head_adam"}
|
||||
required |= ({"uniform", "uniform_temperature", "slsqp_weights", "ensemble_pso"} if family == "classification" else {"uniform_wbf", "ensemble_pso", "ensemble_random"})
|
||||
for method in sorted(required):
|
||||
if not _method_evidence(confirmation, method, family, root):
|
||||
_issue(issues, "metrics", f"{workload}: confirmation lacks predictions/metrics for required method {method}")
|
||||
|
||||
def _method_cells(result: Mapping[str, Any], method: str) -> list[dict[str, Any]]:
|
||||
arms = result.get("arms")
|
||||
if not isinstance(arms, Mapping):
|
||||
return []
|
||||
value = arms.get(method)
|
||||
return _collect_cells(value) if value is not None else []
|
||||
|
||||
|
||||
def _verify_matrix(result: Mapping[str, Any], workload: str, issues: dict[str, list[str]]) -> dict[str, Any]:
|
||||
arms = result.get("arms")
|
||||
if not isinstance(arms, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: arms must be an object")
|
||||
arms = {}
|
||||
required = {"feature_pso", "feature_random", "feature_adam", "head_adam"}
|
||||
missing = required - set(arms)
|
||||
if missing:
|
||||
_issue(issues, "matrix", f"{workload}: missing arms {sorted(missing)}")
|
||||
stats: dict[str, Any] = {"primary_queries": 0, "primary_random_queries": 0, "ensemble_queries": 0, "primary_samples": 0, "primary_random_samples": 0, "ensemble_samples": 0, "cells": {}}
|
||||
for method in ("feature_pso", "feature_random"):
|
||||
cells = _method_cells(result, method)
|
||||
stats["cells"][method] = len(cells)
|
||||
expected = len(BASE_SEEDS) * len(SWARM_SEEDS)
|
||||
if len(cells) != expected:
|
||||
_issue(issues, "matrix", f"{workload}: {method} requires {expected} base/swarm cells, got {len(cells)}")
|
||||
seen: set[tuple[int, int]] = set()
|
||||
for cell in cells:
|
||||
key = (_int(cell, "base_seed") or -1, _int(cell, "swarm_seed") or -1)
|
||||
if key in seen or key[0] not in BASE_SEEDS or key[1] not in SWARM_SEEDS:
|
||||
_issue(issues, "matrix", f"{workload}: invalid or duplicate {method} cell {key}")
|
||||
seen.add(key)
|
||||
queries, samples = _record_queries(cell)
|
||||
if queries != PRIMARY_QUERIES:
|
||||
_issue(issues, "accounting", f"{workload}: {method} {key} queries must be {PRIMARY_QUERIES}, got {queries}")
|
||||
if samples != PRIMARY_QUERIES * OBJECTIVE_SAMPLES[workload]:
|
||||
_issue(issues, "accounting", f"{workload}: {method} {key} samples must be {PRIMARY_QUERIES * OBJECTIVE_SAMPLES[workload]}, got {samples}")
|
||||
if method == "feature_pso":
|
||||
if queries is not None: stats["primary_queries"] += queries
|
||||
if samples is not None: stats["primary_samples"] += samples
|
||||
else:
|
||||
if queries is not None: stats["primary_random_queries"] += queries
|
||||
if samples is not None: stats["primary_random_samples"] += samples
|
||||
for method in ("feature_adam", "head_adam"):
|
||||
cells = _method_base_cells(result, method); stats["cells"][method] = len(cells)
|
||||
if len(cells) != len(BASE_SEEDS): _issue(issues, "matrix", f"{workload}: {method} requires exactly three base cells, got {len(cells)}")
|
||||
seen = set()
|
||||
for cell in cells:
|
||||
seed = _int(cell, "base_seed")
|
||||
if seed in seen or seed not in BASE_SEEDS: _issue(issues, "matrix", f"{workload}: invalid or duplicate {method} base cell {seed}")
|
||||
if seed is not None: seen.add(seed)
|
||||
|
||||
ensemble = result.get("ensemble")
|
||||
if not isinstance(ensemble, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: ensemble must be an object")
|
||||
ensemble = {}
|
||||
required_ensemble = ("uniform", "uniform_temperature", "slsqp_weights", "ensemble_pso") if workload in CLASSIFICATION_WORKLOADS else ("uniform_wbf", "ensemble_pso", "ensemble_random")
|
||||
for method in required_ensemble:
|
||||
if method not in ensemble or ensemble.get(method) in (None, {}, []): _issue(issues, "matrix", f"{workload}: missing required ensemble method {method}")
|
||||
ens_pso = ensemble.get("ensemble_pso", ensemble.get("pso"))
|
||||
cells = _collect_cells(ens_pso) if ens_pso is not None else []
|
||||
stats["cells"]["ensemble_pso"] = len(cells)
|
||||
if len(cells) != len(SWARM_SEEDS):
|
||||
_issue(issues, "matrix", f"{workload}: ensemble_pso requires three swarm cells, got {len(cells)}")
|
||||
seen_swarm: set[int] = set()
|
||||
for cell in cells:
|
||||
seed = _int(cell, "swarm_seed", "seed")
|
||||
if seed in seen_swarm or seed not in SWARM_SEEDS:
|
||||
_issue(issues, "matrix", f"{workload}: invalid or duplicate ensemble swarm cell {seed}")
|
||||
if seed is not None: seen_swarm.add(seed)
|
||||
queries, samples = _record_queries(cell)
|
||||
if queries != ENSEMBLE_QUERIES:
|
||||
_issue(issues, "accounting", f"{workload}: ensemble_pso queries must be {ENSEMBLE_QUERIES}, got {queries}")
|
||||
if samples != ENSEMBLE_QUERIES * OBJECTIVE_SAMPLES[workload]:
|
||||
_issue(issues, "accounting", f"{workload}: ensemble_pso samples must be {ENSEMBLE_QUERIES * OBJECTIVE_SAMPLES[workload]}, got {samples}")
|
||||
if queries is not None: stats["ensemble_queries"] += queries
|
||||
if samples is not None: stats["ensemble_samples"] += samples
|
||||
if workload == DETECTION_WORKLOAD:
|
||||
random_cells = _collect_cells(ensemble.get("ensemble_random")) if ensemble.get("ensemble_random") is not None else []
|
||||
stats["cells"]["ensemble_random"] = len(random_cells)
|
||||
if len(random_cells) != len(SWARM_SEEDS): _issue(issues, "matrix", f"{workload}: ensemble_random requires three swarm cells, got {len(random_cells)}")
|
||||
seen_random = set()
|
||||
for cell in random_cells:
|
||||
seed = _int(cell, "swarm_seed", "seed")
|
||||
if seed in seen_random or seed not in SWARM_SEEDS: _issue(issues, "matrix", f"{workload}: invalid or duplicate ensemble_random seed {seed}")
|
||||
if seed is not None: seen_random.add(seed)
|
||||
return stats
|
||||
|
||||
|
||||
def _verify_selection(result: Mapping[str, Any], workload: str, issues: dict[str, list[str]]) -> None:
|
||||
selection = result.get("development_selection")
|
||||
if not isinstance(selection, Mapping):
|
||||
_issue(issues, "selection", f"{workload}: missing development_selection")
|
||||
return
|
||||
selected = selection.get("primary", selection.get("feature_pso", selection.get("selected")))
|
||||
if not isinstance(selected, Mapping):
|
||||
_issue(issues, "selection", f"{workload}: missing primary selected endpoints")
|
||||
return
|
||||
for base in BASE_SEEDS:
|
||||
entry = selected.get(str(base), selected.get(base))
|
||||
if not isinstance(entry, Mapping):
|
||||
_issue(issues, "selection", f"{workload}: no selected endpoint for base seed {base}")
|
||||
continue
|
||||
swarm = _int(entry, "swarm_seed", "seed")
|
||||
generation = _int(entry, "generation", "final_generation")
|
||||
if swarm not in SWARM_SEEDS:
|
||||
_issue(issues, "selection", f"{workload}: selected seed {base} has invalid swarm {swarm}")
|
||||
if generation != PRIMARY_GENERATIONS:
|
||||
_issue(issues, "selection", f"{workload}: selected endpoint {base} is not final generation 60")
|
||||
matches = [c for c in _method_cells(result, "feature_pso") if _int(c, "base_seed") == base and _int(c, "swarm_seed") == swarm]
|
||||
if not matches:
|
||||
_issue(issues, "selection", f"{workload}: selected endpoint {base}/{swarm} is not a feature_pso cell")
|
||||
elif entry.get("vector_hash") and matches[0].get("vector_hash") and entry["vector_hash"] != matches[0]["vector_hash"]:
|
||||
_issue(issues, "selection", f"{workload}: selected vector hash drift for base {base}")
|
||||
|
||||
|
||||
def _prediction_records(value: Any, root: Path) -> list[dict[str, Any]] | None:
|
||||
try: value = _resolve_json(root, value)
|
||||
except (OSError, ValueError, json.JSONDecodeError): return None
|
||||
if isinstance(value, Mapping):
|
||||
if "probabilities" in value and "targets" in value:
|
||||
probabilities, targets = value["probabilities"], value["targets"]
|
||||
for attr in ("detach", "cpu"):
|
||||
if hasattr(probabilities, attr): probabilities = getattr(probabilities, attr)()
|
||||
if hasattr(targets, attr): targets = getattr(targets, attr)()
|
||||
if hasattr(probabilities, "tolist"): probabilities = probabilities.tolist()
|
||||
if hasattr(targets, "tolist"): targets = targets.tolist()
|
||||
if isinstance(probabilities, (list, tuple)) and isinstance(targets, (list, tuple)) and len(probabilities) == len(targets):
|
||||
return [{"probabilities": list(probability), "target": int(target)} for probability, target in zip(probabilities, targets)]
|
||||
return None
|
||||
for key in ("records", "predictions", "images", "examples", "data"):
|
||||
if key in value:
|
||||
got = _prediction_records(value[key], root)
|
||||
if got is not None: return got
|
||||
return None
|
||||
if isinstance(value, list) and all(isinstance(x, Mapping) for x in value):
|
||||
return [dict(x) for x in value]
|
||||
return None
|
||||
|
||||
|
||||
def _find_prediction_sets(value: Any, root: Path, prefix: str = "$") -> dict[str, list[dict[str, Any]]]:
|
||||
found: dict[str, list[dict[str, Any]]] = {}
|
||||
if isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
name = f"{prefix}.{key}"
|
||||
if "prediction" in str(key).lower() or str(key).lower() in {"base", "pso", "feature_pso", "test"}:
|
||||
records = _prediction_records(item, root)
|
||||
if records is not None: found[name] = records
|
||||
found.update(_find_prediction_sets(item, root, name))
|
||||
elif isinstance(value, list) and value and isinstance(value[0], Mapping):
|
||||
records = _prediction_records(value, root)
|
||||
if records is not None: found[prefix] = records
|
||||
return found
|
||||
|
||||
|
||||
def classification_metrics(records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
"""Recompute unrounded NLL and accuracy from per-image probabilities/targets."""
|
||||
probs: list[list[float]] = []; targets: list[int] = []
|
||||
for i, record in enumerate(records):
|
||||
p = record.get("probabilities", record.get("probs", record.get("prob")))
|
||||
target = record.get("target", record.get("label", record.get("class_id")))
|
||||
if not isinstance(p, (list, tuple)) or not p or not isinstance(target, int) or isinstance(target, bool) or target < 0 or target >= len(p):
|
||||
raise ValueError(f"invalid classification prediction at index {i}")
|
||||
vals = [float(x) for x in p]
|
||||
if not all(math.isfinite(x) and x >= 0.0 for x in vals) or not math.isclose(math.fsum(vals), 1.0, rel_tol=1e-6, abs_tol=1e-6):
|
||||
raise ValueError(f"probabilities must be finite and sum to one at index {i}")
|
||||
probs.append(vals); targets.append(target)
|
||||
nll = math.fsum(
|
||||
-math.log(max(p[t], 1e-300)) for p, t in zip(probs, targets)
|
||||
) / len(probs)
|
||||
predictions = [
|
||||
max(range(len(p)), key=p.__getitem__) for p in probs
|
||||
]
|
||||
accuracy = sum(
|
||||
prediction == target
|
||||
for prediction, target in zip(predictions, targets)
|
||||
) / len(probs)
|
||||
brier = math.fsum(
|
||||
math.fsum(
|
||||
(probability - float(index == target)) ** 2
|
||||
for index, probability in enumerate(p)
|
||||
)
|
||||
for p, target in zip(probs, targets)
|
||||
) / len(probs)
|
||||
confidences = [p[prediction] for p, prediction in zip(probs, predictions)]
|
||||
ece = 0.0
|
||||
for bin_index in range(15):
|
||||
lower = bin_index / 15.0
|
||||
upper = (bin_index + 1) / 15.0
|
||||
members = [
|
||||
index
|
||||
for index, confidence in enumerate(confidences)
|
||||
if lower <= confidence <= upper
|
||||
if bin_index == 14 or confidence < upper
|
||||
]
|
||||
if members:
|
||||
bin_accuracy = math.fsum(
|
||||
predictions[index] == targets[index] for index in members
|
||||
) / len(members)
|
||||
bin_confidence = math.fsum(
|
||||
confidences[index] for index in members
|
||||
) / len(members)
|
||||
ece += (
|
||||
abs(bin_accuracy - bin_confidence)
|
||||
* len(members)
|
||||
/ len(probs)
|
||||
)
|
||||
return {
|
||||
"n": len(probs),
|
||||
"nll": nll,
|
||||
"accuracy": accuracy,
|
||||
"brier": brier,
|
||||
"ece15": ece,
|
||||
"probabilities": probs,
|
||||
"targets": targets,
|
||||
}
|
||||
|
||||
|
||||
def _box(record: Mapping[str, Any]) -> tuple[float, float, float, float] | None:
|
||||
value = record.get("box", record.get("bbox", record.get("xyxy")))
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4 or not all(_finite(x) for x in value): return None
|
||||
x1, y1, x2, y2 = map(float, value)
|
||||
return (x1, y1, x2, y2) if x2 >= x1 and y2 >= y1 else None
|
||||
|
||||
|
||||
def _iou(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
x1, y1 = max(a[0], b[0]), max(a[1], b[1]); x2, y2 = min(a[2], b[2]), min(a[3], b[3])
|
||||
inter = max(0.0, x2 - x1) * max(0.0, y2 - y1)
|
||||
area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1]); area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
|
||||
return inter / (area_a + area_b - inter) if area_a + area_b - inter > 0 else 0.0
|
||||
|
||||
|
||||
def _interp_ap_101(recall: Sequence[float], precision: Sequence[float]) -> float:
|
||||
"""Ultralytics compute_ap: precision envelope and 101-point trapezoid."""
|
||||
mrec = [0.0, *map(float, recall), 1.0]
|
||||
mpre = [1.0, *map(float, precision), 0.0]
|
||||
for i in range(len(mpre) - 2, -1, -1): mpre[i] = max(mpre[i], mpre[i + 1])
|
||||
values: list[float] = []
|
||||
for k in range(101):
|
||||
x = k / 100.0; j = 0
|
||||
while j + 1 < len(mrec) and mrec[j + 1] <= x: j += 1
|
||||
if j + 1 >= len(mrec): values.append(mpre[-1]); continue
|
||||
span = mrec[j + 1] - mrec[j]
|
||||
values.append(mpre[j] if span <= 0 else mpre[j] + (mpre[j + 1] - mpre[j]) * (x - mrec[j]) / span)
|
||||
return sum((values[i] + values[i + 1]) * 0.5 / 100.0 for i in range(100))
|
||||
|
||||
|
||||
def detection_metrics(records: Sequence[Mapping[str, Any]], class_count: int | None = None) -> dict[str, Any]:
|
||||
"""Recompute Ultralytics-style whole-dataset AP at IoU .50:.95.
|
||||
|
||||
Matching is performed independently per image. Candidate matches are sorted
|
||||
by IoU and deduplicated by prediction and ground truth, as in
|
||||
DetectionValidator.process_batch; AP then uses the pinned 101-point
|
||||
interpolated trapezoid.
|
||||
"""
|
||||
thresholds = [0.50 + 0.05 * i for i in range(10)]
|
||||
parsed: list[tuple[list[dict[str, Any]], list[dict[str, Any]]]] = []; max_class = -1
|
||||
for i, image in enumerate(records):
|
||||
predictions = image.get("predictions", image.get("detections", image.get("pred", [])))
|
||||
truth = image.get("ground_truth", image.get("targets", image.get("gt", image.get("labels", []))))
|
||||
if not isinstance(predictions, list) or not isinstance(truth, list): raise ValueError(f"invalid detection image record {i}")
|
||||
pp: list[dict[str, Any]] = []; gg: list[dict[str, Any]] = []
|
||||
for item in predictions:
|
||||
if not isinstance(item, Mapping) or _box(item) is None: raise ValueError(f"invalid detection prediction {i}")
|
||||
cls = item.get("class_id", item.get("class", item.get("cls"))); score = item.get("score", item.get("confidence", item.get("conf")))
|
||||
if not isinstance(cls, int) or isinstance(cls, bool) or not _finite(score): raise ValueError(f"invalid detection prediction fields {i}")
|
||||
pp.append({"box": _box(item), "class_id": cls, "score": float(score)}); max_class = max(max_class, cls)
|
||||
for item in truth:
|
||||
if not isinstance(item, Mapping) or _box(item) is None: raise ValueError(f"invalid ground truth {i}")
|
||||
cls = item.get("class_id", item.get("class", item.get("cls")))
|
||||
if not isinstance(cls, int) or isinstance(cls, bool): raise ValueError(f"invalid ground truth class {i}")
|
||||
gg.append({"box": _box(item), "class_id": cls}); max_class = max(max_class, cls)
|
||||
parsed.append((pp, gg))
|
||||
present = sorted({g["class_id"] for _, gt in parsed for g in gt})
|
||||
classes = present if class_count is None else [c for c in range(class_count) if c in present]
|
||||
if not classes: classes = list(range(class_count or (max_class + 1))) or [0]
|
||||
aps: dict[str, list[float]] = {}; precision50: list[float] = []; recall50: list[float] = []
|
||||
for cls in classes:
|
||||
gt_count = sum(sum(x["class_id"] == cls for x in gt) for _, gt in parsed); class_aps: list[float] = []
|
||||
for threshold in thresholds:
|
||||
true_by_image: list[list[bool]] = []
|
||||
for preds, gt in parsed:
|
||||
candidates = []
|
||||
for pi, pred in enumerate(preds):
|
||||
if pred["class_id"] != cls: continue
|
||||
for gi, target in enumerate(gt):
|
||||
if target["class_id"] == cls:
|
||||
overlap = _iou(pred["box"], target["box"])
|
||||
if overlap >= threshold: candidates.append((overlap, pi, gi))
|
||||
candidates.sort(key=lambda x: -x[0]); used_pred: set[int] = set(); used_gt: set[int] = set(); matched: set[int] = set()
|
||||
for overlap, pi, gi in candidates:
|
||||
if pi not in used_pred and gi not in used_gt: used_pred.add(pi); used_gt.add(gi); matched.add(pi)
|
||||
true_by_image.append([i in matched for i in range(len(preds))])
|
||||
ranked = sorted(((pred["score"], hit) for (preds, _), hits in zip(parsed, true_by_image) for pred, hit in zip(preds, hits) if pred["class_id"] == cls), key=lambda x: -x[0])
|
||||
tp=[]; fp=[]; ctp=cfp=0
|
||||
for _, hit in ranked:
|
||||
ctp += int(hit); cfp += int(not hit); tp.append(ctp); fp.append(cfp)
|
||||
if gt_count == 0 or not ranked:
|
||||
# Ultralytics ap_per_class skips classes with no predictions; AP is zero.
|
||||
class_aps.append(0.0); continue
|
||||
recalls = [x / gt_count for x in tp]; precisions = [x / max(x + y, 1) for x, y in zip(tp, fp)]
|
||||
class_aps.append(_interp_ap_101(recalls, precisions))
|
||||
if threshold == 0.5:
|
||||
precision50.append(precisions[-1] if precisions else 0.0); recall50.append(recalls[-1] if recalls else 0.0)
|
||||
aps[str(cls)] = class_aps
|
||||
map50 = math.fsum(v[0] for v in aps.values()) / len(aps); map5095 = math.fsum(x for values in aps.values() for x in values) / (len(aps) * 10)
|
||||
return {"per_class_ap": aps, "n": len(records), "map50": map50, "map50_95": map5095,
|
||||
"precision": math.fsum(precision50) / len(precision50) if precision50 else 0.0,
|
||||
"recall": math.fsum(recall50) / len(recall50) if recall50 else 0.0,
|
||||
"ground_truth": sum(len(gt) for _, gt in parsed), "predictions": sum(len(preds) for preds, _ in parsed)}
|
||||
|
||||
def _metric_from_record(record: Any, family: str) -> dict[str, float] | None:
|
||||
if not isinstance(record, Mapping): return None
|
||||
keys = ("nll", "loss") if family == "classification" else ("map50_95", "map50-95", "mAP50-95", "map5095")
|
||||
primary = _number(record, *keys); accuracy = _number(record, "accuracy", "acc")
|
||||
map50 = _number(record, "map50", "mAP50")
|
||||
if family == "classification" and primary is not None and accuracy is not None: return {"nll": primary, "accuracy": accuracy}
|
||||
if family == "detection" and primary is not None: return {"map50_95": primary, **({"map50": map50} if map50 is not None else {})}
|
||||
return None
|
||||
|
||||
|
||||
def _quantile(values: Sequence[float], q: float) -> float:
|
||||
ordered = sorted(float(x) for x in values)
|
||||
if not ordered: raise ValueError("cannot quantile an empty sequence")
|
||||
position = (len(ordered) - 1) * q; lower = int(math.floor(position)); upper = min(lower + 1, len(ordered) - 1)
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
||||
|
||||
|
||||
def _record_identity(record: Mapping[str, Any], index: int) -> Any:
|
||||
for key in ("image_id", "id", "key", "filename", "path", "index"):
|
||||
if key in record: return (key, str(record[key]))
|
||||
return ("position", index)
|
||||
|
||||
|
||||
def _record_ground_truth(record: Mapping[str, Any]) -> Any:
|
||||
return record.get("ground_truth", record.get("targets", record.get("gt", record.get("labels", []))))
|
||||
|
||||
|
||||
def _bootstrap_alignment(pairs: Sequence[tuple[Sequence[Mapping[str, Any]], Sequence[Mapping[str, Any]]]], family: str) -> tuple[bool, str]:
|
||||
if not pairs or any(len(a) != len(b) or not a for a, b in pairs): return False, "incomplete or length-mismatched paired records"
|
||||
reference_ids = [_record_identity(x, i) for i, x in enumerate(pairs[0][0])]
|
||||
reference_gt = [_record_ground_truth(x) for x in pairs[0][0]]
|
||||
for pair_index, (base, pso) in enumerate(pairs):
|
||||
if [_record_identity(x, i) for i, x in enumerate(base)] != reference_ids or [_record_identity(x, i) for i, x in enumerate(pso)] != reference_ids:
|
||||
return False, f"pair {pair_index} image IDs/order differ"
|
||||
if family == "classification":
|
||||
base_targets = [x.get("target", x.get("label", x.get("class_id"))) for x in base]
|
||||
pso_targets = [x.get("target", x.get("label", x.get("class_id"))) for x in pso]
|
||||
ref_targets = [x.get("target", x.get("label", x.get("class_id"))) for x in pairs[0][0]]
|
||||
if base_targets != ref_targets or pso_targets != ref_targets: return False, f"pair {pair_index} targets differ"
|
||||
elif [_canonical(_record_ground_truth(x)) for x in base] != [_canonical(x) for x in reference_gt] or [_canonical(_record_ground_truth(x)) for x in pso] != [_canonical(x) for x in reference_gt]:
|
||||
return False, f"pair {pair_index} ground truth differs"
|
||||
return True, "aligned"
|
||||
|
||||
|
||||
def _bootstrap_from_records(pairs: Sequence[tuple[Sequence[Mapping[str, Any]], Sequence[Mapping[str, Any]]]], family: str) -> dict[str, Any]:
|
||||
aligned, reason = _bootstrap_alignment(pairs, family)
|
||||
if not aligned: return {"available": False, "seed": BOOTSTRAP_SEED, "resamples": BOOTSTRAP_RESAMPLES, "reason": reason}
|
||||
rng = random.Random(BOOTSTRAP_SEED); stats: list[float] = []
|
||||
if family == "classification":
|
||||
parsed = [(classification_metrics(a), classification_metrics(b)) for a, b in pairs]
|
||||
by_class: dict[int, list[int]] = {}
|
||||
for i, target in enumerate(parsed[0][0]["targets"]): by_class.setdefault(target, []).append(i)
|
||||
for _ in range(BOOTSTRAP_RESAMPLES):
|
||||
# One class-stratified draw is shared by every paired base/PSO model.
|
||||
indices = [rng.choice(class_indices) for class_indices in by_class.values() for _ in class_indices]
|
||||
deltas = []
|
||||
for base, pso in parsed:
|
||||
b_nll = math.fsum(-math.log(max(base["probabilities"][i][base["targets"][i]], 1e-300)) for i in indices) / len(indices)
|
||||
p_nll = math.fsum(-math.log(max(pso["probabilities"][i][pso["targets"][i]], 1e-300)) for i in indices) / len(indices)
|
||||
deltas.append((b_nll - p_nll) / b_nll if b_nll else 0.0)
|
||||
stats.append(math.fsum(deltas) / len(deltas))
|
||||
else:
|
||||
for _ in range(BOOTSTRAP_RESAMPLES):
|
||||
# One whole-image draw is shared by every paired base/PSO model.
|
||||
indices = [rng.randrange(len(pairs[0][0])) for _ in pairs[0][0]]
|
||||
deltas = []
|
||||
for base, pso in pairs:
|
||||
b = detection_metrics([base[i] for i in indices])["map50_95"]
|
||||
p = detection_metrics([pso[i] for i in indices])["map50_95"]
|
||||
deltas.append(p - b)
|
||||
stats.append(math.fsum(deltas) / len(deltas))
|
||||
lo = _quantile(stats, BOOTSTRAP_ALPHA); hi = _quantile(stats, 1.0 - BOOTSTRAP_ALPHA)
|
||||
return {"available": True, "seed": BOOTSTRAP_SEED, "resamples": BOOTSTRAP_RESAMPLES, "alpha": BOOTSTRAP_ALPHA, "lower": lo, "upper": hi, "statistic": math.fsum(stats) / len(stats), "excludes_zero": lo > 0 or hi < 0}
|
||||
|
||||
def _prediction_pairs(result: Mapping[str, Any], root: Path, family: str) -> list[tuple[int, list[dict[str, Any]], list[dict[str, Any]]]]:
|
||||
"""Find one test base/selected pair per frozen base seed by explicit names."""
|
||||
sets = _find_prediction_sets(result.get("confirmation", {}), root)
|
||||
out: list[tuple[int, list[dict[str, Any]], list[dict[str, Any]]]] = []
|
||||
for seed in BASE_SEEDS:
|
||||
candidates = [(name, records) for name, records in sets.items() if str(seed) in name]
|
||||
base = next((records for name, records in candidates if any(x in name.lower() for x in ("base", "frozen"))), None)
|
||||
pso = next((records for name, records in candidates if any(x in name.lower() for x in ("feature_pso", "selected", "pso")) and "ensemble" not in name.lower()), None)
|
||||
if base is not None and pso is not None: out.append((seed, base, pso))
|
||||
return out
|
||||
|
||||
|
||||
def _audit_series(value: Any) -> list[dict[str, Any]]:
|
||||
found: list[dict[str, Any]] = []
|
||||
if isinstance(value, Mapping):
|
||||
for item in value.values(): found.extend(_audit_series(item))
|
||||
elif isinstance(value, list) and value and all(isinstance(item, Mapping) for item in value):
|
||||
if any("epoch" in item or "step" in item for item in value) and any(_number(item, "loss", "audit_loss") is not None for item in value):
|
||||
found.append({"records": value})
|
||||
else:
|
||||
for item in value: found.extend(_audit_series(item))
|
||||
return found
|
||||
|
||||
|
||||
def _plateau_flags(result: Mapping[str, Any], workload: str, family: str, issues: dict[str, list[str]]) -> dict[str, Any]:
|
||||
baselines = result.get("baselines", {})
|
||||
if not isinstance(baselines, Mapping): return {"available": False, "passed": False}
|
||||
per_seed: dict[str, bool] = {}; details: dict[str, Any] = {}
|
||||
for seed in BASE_SEEDS:
|
||||
entry = baselines.get(str(seed), baselines.get(seed))
|
||||
series = _audit_series(entry)
|
||||
records = series[0]["records"] if series else []
|
||||
records = records[-11:] if len(records) >= 11 else []
|
||||
losses = [_number(x, "loss", "audit_loss") for x in records]
|
||||
metric_keys = ("accuracy", "primary_metric", "selection_accuracy") if family == "classification" else ("map50_95", "mAP50-95", "primary_metric", "selection_metric")
|
||||
metrics = [_number(x, *metric_keys) for x in records]
|
||||
loss_ok = len(losses) == 11 and all(x is not None for x in losses)
|
||||
metric_ok = len(metrics) == 11 and all(x is not None for x in metrics)
|
||||
if loss_ok:
|
||||
mean = math.fsum(float(x) for x in losses) / len(losses)
|
||||
loss_ok = (max(losses) - min(losses)) / max(abs(mean), 1e-12) <= 0.01
|
||||
if metric_ok: metric_ok = max(metrics) - min(metrics) <= 0.005
|
||||
passed = bool(loss_ok and metric_ok); per_seed[str(seed)] = passed
|
||||
details[str(seed)] = {"loss_ok": bool(loss_ok), "metric_ok": bool(metric_ok), "observations": len(records)}
|
||||
declared = _number(entry, "baseline_plateau") if isinstance(entry, Mapping) else None
|
||||
if isinstance(entry, Mapping) and "baseline_plateau" in entry and bool(entry["baseline_plateau"]) != passed:
|
||||
_issue(issues, "plateau", f"{workload}: baseline seed {seed} inflated/incorrect plateau flag")
|
||||
return {"available": bool(per_seed), "per_seed": per_seed, "details": details, "passed": bool(per_seed) and all(per_seed.values())}
|
||||
|
||||
|
||||
def _workload_gates(result: Mapping[str, Any], workload: str, family: str, root: Path, issues: dict[str, list[str]]) -> dict[str, Any]:
|
||||
pairs = _prediction_pairs(result, root, family)
|
||||
per_seed: dict[str, Any] = {}
|
||||
pair_records: list[tuple[Sequence[Mapping[str, Any]], Sequence[Mapping[str, Any]]]] = []
|
||||
for seed, base_records, pso_records in pairs:
|
||||
try:
|
||||
base = classification_metrics(base_records) if family == "classification" else detection_metrics(base_records, 20)
|
||||
pso = classification_metrics(pso_records) if family == "classification" else detection_metrics(pso_records, 20)
|
||||
except (ValueError, ZeroDivisionError) as exc:
|
||||
_issue(issues, "metrics", f"{workload}: test pair {seed} cannot be recomputed: {exc}"); continue
|
||||
pair_records.append((base_records, pso_records))
|
||||
if family == "classification": per_seed[str(seed)] = {"base": {"nll": base["nll"], "accuracy": base["accuracy"]}, "pso": {"nll": pso["nll"], "accuracy": pso["accuracy"]}, "relative_nll_reduction": (base["nll"] - pso["nll"]) / base["nll"], "accuracy_delta": pso["accuracy"] - base["accuracy"]}
|
||||
else: per_seed[str(seed)] = {"base": {"map50_95": base["map50_95"], "map50": base["map50"]}, "pso": {"map50_95": pso["map50_95"], "map50": pso["map50"]}, "map50_95_delta": pso["map50_95"] - base["map50_95"], "map50_delta": pso["map50"] - base["map50"]}
|
||||
ci = _bootstrap_from_records(pair_records, family)
|
||||
# Confirmation predictions and the paired CI are required integrity evidence.
|
||||
# A numerically negative CI is a valid scientific result; an unavailable CI
|
||||
# means the frozen/confirmed artifact set is incomplete or tampered.
|
||||
if len(pairs) != len(BASE_SEEDS):
|
||||
_issue(issues, "metrics", f"{workload}: required confirmation pairs are incomplete ({len(pairs)}/{len(BASE_SEEDS)})")
|
||||
if not ci.get("available", False):
|
||||
_issue(issues, "metrics", f"{workload}: required paired bootstrap unavailable: {ci.get('reason', 'missing prediction evidence')}")
|
||||
if family == "classification" and per_seed:
|
||||
reductions = [x["relative_nll_reduction"] for x in per_seed.values()]; acc_deltas = [x["accuracy_delta"] for x in per_seed.values()]
|
||||
gate = len(reductions) == 3 and math.fsum(reductions) / 3 >= 0.01 and math.fsum(acc_deltas) / 3 >= -0.002 and min(acc_deltas) >= -0.005 and sum(x > 0 for x in reductions) >= 2 and ci.get("excludes_zero", False)
|
||||
elif family == "detection" and per_seed:
|
||||
deltas = [x["map50_95_delta"] for x in per_seed.values()]
|
||||
gate = len(deltas) == 3 and math.fsum(deltas) / 3 >= 0.005 and min(deltas) >= -0.005 and sum(x > 0 for x in deltas) >= 2 and ci.get("excludes_zero", False)
|
||||
else: gate = False
|
||||
if len(pairs) != len(BASE_SEEDS): _issue(issues, "bootstrap", f"{workload}: missing complete official-test base/feature_pso prediction pairs ({len(pairs)}/3)")
|
||||
return {"available": bool(per_seed), "per_seed": per_seed, "bootstrap": ci, "generalization_pass": bool(gate)}
|
||||
|
||||
|
||||
def _verify_hashes(result: Mapping[str, Any], root: Path, issues: dict[str, list[str]], workload: str) -> None:
|
||||
declared = result.get("artifact_hashes")
|
||||
if not isinstance(declared, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: missing artifact_hashes")
|
||||
return
|
||||
for name, expected in declared.items():
|
||||
if not isinstance(name, str) or not isinstance(expected, str):
|
||||
_issue(issues, "seal", f"{workload}: malformed artifact hash entry")
|
||||
continue
|
||||
path = (root / name).resolve()
|
||||
if root.resolve() not in path.parents or not path.is_file():
|
||||
_issue(issues, "seal", f"{workload}: missing/escaping artifact {name}")
|
||||
elif _sha256(path) != expected:
|
||||
_issue(issues, "seal", f"{workload}: artifact hash drift {name}")
|
||||
|
||||
|
||||
def _compare_development_snapshot(development: Any, current: Mapping[str, Any], workload: str, issues: dict[str, list[str]]) -> None:
|
||||
"""Ensure confirmation did not alter any sealed development decision/state."""
|
||||
if not isinstance(development, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: development_result.json must contain an object")
|
||||
return
|
||||
development_leakage = development.get("leakage_counters")
|
||||
if not isinstance(development_leakage, Mapping):
|
||||
_issue(issues, "leakage", f"{workload}: development snapshot lacks leakage_counters")
|
||||
else:
|
||||
loaded = development_leakage.get("official_test_data_loaded_before_freeze", development_leakage.get("test_data_loaded_before_freeze"))
|
||||
evaluated = development_leakage.get("official_test_evaluations_before_freeze", development_leakage.get("test_evaluations_before_freeze", development_leakage.get("official_test_forward_passes_before_freeze")))
|
||||
if loaded is not False or evaluated != 0:
|
||||
_issue(issues, "leakage", f"{workload}: development snapshot records pre-freeze official-test exposure")
|
||||
for key in ("official_test_construction", "official_test_dataset_construction", "official_test_forward_passes", "official_test_evaluations"):
|
||||
if key in development_leakage and development_leakage[key] != 0:
|
||||
_issue(issues, "leakage", f"{workload}: development snapshot {key} must be zero")
|
||||
development_confirmation = development.get("confirmation")
|
||||
if development_confirmation not in ({}, None):
|
||||
_issue(issues, "leakage", f"{workload}: development snapshot confirmation must be empty")
|
||||
fields = ("config", "manifests", "provenance", "baselines", "arms", "ensemble", "development_selection", "integrity", "resource_ledger", "artifact_hashes")
|
||||
for field in fields:
|
||||
if field not in development:
|
||||
_issue(issues, "seal", f"{workload}: development_result.json missing sealed field {field}")
|
||||
continue
|
||||
if field not in current:
|
||||
_issue(issues, "seal", f"{workload}: current result missing sealed field {field}")
|
||||
continue
|
||||
if field == "artifact_hashes":
|
||||
# Confirmation may add test prediction hashes. Every development hash
|
||||
# must nevertheless remain present and byte-identical.
|
||||
old_hashes = development[field]; new_hashes = current[field]
|
||||
if not isinstance(old_hashes, Mapping) or not isinstance(new_hashes, Mapping):
|
||||
_issue(issues, "seal", f"{workload}: artifact_hashes changed shape across confirmation")
|
||||
else:
|
||||
for name, value in old_hashes.items():
|
||||
if new_hashes.get(name) != value: _issue(issues, "seal", f"{workload}: sealed artifact hash drift for {name}")
|
||||
elif field == "integrity":
|
||||
old_integrity = development[field]; new_integrity = current[field]
|
||||
if not isinstance(old_integrity, Mapping) or not isinstance(new_integrity, Mapping):
|
||||
if old_integrity != new_integrity: _issue(issues, "seal", f"{workload}: pre-confirmation integrity changed")
|
||||
else:
|
||||
for name, value in old_integrity.items():
|
||||
if new_integrity.get(name) != value: _issue(issues, "seal", f"{workload}: pre-confirmation integrity field changed: {name}")
|
||||
elif _canonical(development[field]) != _canonical(current[field]):
|
||||
_issue(issues, "seal", f"{workload}: sealed pre-confirmation field changed: {field}")
|
||||
|
||||
def _evaluate_workload(result: Any, root: Path, workload: str, issues: dict[str, list[str]], development: Any = None) -> dict[str, Any]:
|
||||
if not isinstance(result, Mapping):
|
||||
_issue(issues, "schema", f"{workload}: result must be an object"); return {"workload_id": workload, "valid": False}
|
||||
if result.get("workload_id") != workload: _issue(issues, "matrix", f"{workload}: workload_id mismatch")
|
||||
if development is not None: _compare_development_snapshot(development, result, workload, issues)
|
||||
family = "detection" if workload == DETECTION_WORKLOAD else "classification"
|
||||
if result.get("family") != family: _issue(issues, "matrix", f"{workload}: family must be {family}")
|
||||
for key in ("manifests", "provenance", "baselines", "arms", "ensemble", "development_selection", "confirmation", "integrity", "leakage_counters", "resource_ledger", "artifact_hashes"):
|
||||
if key not in result: _issue(issues, "schema", f"{workload}: missing top-level {key}")
|
||||
_config_checks(result.get("config"), issues, workload); _check_leakage(result, issues, workload); _verify_hashes(result, root, issues, workload)
|
||||
accounting = _verify_matrix(result, workload, issues); _verify_selection(result, workload, issues); _require_confirmation_methods(result, workload, family, root, issues)
|
||||
if result.get("integrity", {}).get("confirmed") is False if isinstance(result.get("integrity"), Mapping) else False:
|
||||
_issue(issues, "seal", f"{workload}: integrity declares confirmation failure")
|
||||
prediction_sets = _find_prediction_sets(result.get("confirmation", {}), root)
|
||||
recomputed: dict[str, Any] = {}
|
||||
for name, records in prediction_sets.items():
|
||||
try: recomputed[name] = classification_metrics(records) if family == "classification" else detection_metrics(records, 20)
|
||||
except (ValueError, ZeroDivisionError) as exc: _issue(issues, "metrics", f"{workload}: invalid stored predictions at {name}: {exc}")
|
||||
# Check every explicitly stored metric that has a corresponding recomputation.
|
||||
for name, metric in recomputed.items():
|
||||
if "pso" in name.lower() and isinstance(metric, Mapping):
|
||||
stored = _metric_from_record(result.get("confirmation"), family)
|
||||
if stored and family == "classification" and not (_same(stored.get("nll"), metric.get("nll"), 1e-7) and _same(stored.get("accuracy"), metric.get("accuracy"), 1e-7)):
|
||||
_issue(issues, "metrics", f"{workload}: stored classification metric disagrees with probabilities at {name}")
|
||||
plateau = _plateau_flags(result, workload, family, issues)
|
||||
gates = _workload_gates(result, workload, family, root, issues)
|
||||
objective = _number(result.get("development_selection"), "objective_improvement", "relative_objective_improvement")
|
||||
selection_metric = _number(result.get("development_selection"), "selection_metric", "selection_nll", "selection_map50_95")
|
||||
overfit = bool(objective is not None and objective >= 0.01 and selection_metric is not None and ((family == "classification" and selection_metric > 0.01) or (family == "detection" and selection_metric < -0.005)))
|
||||
if overfit: _issue(issues, "overfit", f"{workload}: objective improvement conflicts with held-out/selection metric")
|
||||
return {"workload_id": workload, "family": family, "valid": True, "accounting": accounting, "recomputed": recomputed, "plateau": plateau, "gates": gates, "overfit_signal": overfit}
|
||||
|
||||
|
||||
def evaluate_run(run_root: str | os.PathLike[str]) -> dict[str, Any]:
|
||||
"""Evaluate one frozen run, returning findings even when artifacts are malformed."""
|
||||
root = Path(run_root); issues = {key: [] for key in ISSUE_CATEGORIES}; workloads: dict[str, Any] = {}
|
||||
manifest: Any = None
|
||||
try: manifest = _json(root / "frozen_manifest.json")
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc: _issue(issues, "schema", f"cannot load frozen_manifest.json: {exc}")
|
||||
if isinstance(manifest, Mapping):
|
||||
for location in _walk_nonfinite(manifest): _issue(issues, "finite", f"non-finite value in frozen manifest at {location}")
|
||||
_hash_manifest(root, manifest, issues); _config_checks(manifest.get("config"), issues)
|
||||
frozen_config = manifest.get("config") if isinstance(manifest.get("config"), Mapping) else {}
|
||||
if sorted(frozen_config.get("workload_ids", ())) != sorted(WORKLOADS): _issue(issues, "matrix", "frozen manifest does not seal all three workloads")
|
||||
for workload in WORKLOADS:
|
||||
path = root / "workloads" / workload / "result.json"
|
||||
development_path = root / "workloads" / workload / "development_result.json"
|
||||
if isinstance(manifest, Mapping):
|
||||
manifest_artifacts = manifest.get("artifacts", {})
|
||||
expected_development = f"workloads/{workload}/development_result.json"
|
||||
if not isinstance(manifest_artifacts, Mapping) or expected_development not in manifest_artifacts:
|
||||
_issue(issues, "seal", f"{workload}: frozen manifest must seal {expected_development}")
|
||||
try: result = _json(path)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
_issue(issues, "schema", f"{workload}: cannot load result.json: {exc}"); continue
|
||||
try: development = _json(development_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
_issue(issues, "seal", f"{workload}: cannot load development_result.json: {exc}"); development = None
|
||||
for location in _walk_nonfinite(result): _issue(issues, "finite", f"{workload}: non-finite value at {location}")
|
||||
if development is not None:
|
||||
for location in _walk_nonfinite(development): _issue(issues, "finite", f"{workload}: non-finite development value at {location}")
|
||||
workloads[workload] = _evaluate_workload(result, root, workload, issues, development)
|
||||
# Cross-workload exact accounting is intentionally independent of stored totals.
|
||||
totals = {key: sum(int(w.get("accounting", {}).get(key, 0)) for w in workloads.values()) for key in ("primary_queries", "primary_random_queries", "ensemble_queries", "primary_samples", "primary_random_samples", "ensemble_samples")}
|
||||
totals["pso_queries"] = totals["primary_queries"] + totals["ensemble_queries"]
|
||||
totals["candidate_samples"] = totals["primary_samples"] + totals["ensemble_samples"]
|
||||
if totals["pso_queries"] != TOTAL_PSO_QUERIES: _issue(issues, "accounting", f"total PSO queries must be {TOTAL_PSO_QUERIES}, got {totals['pso_queries']}")
|
||||
if totals["candidate_samples"] != TOTAL_CANDIDATE_SAMPLES: _issue(issues, "accounting", f"total candidate-sample evaluations must be {TOTAL_CANDIDATE_SAMPLES}, got {totals['candidate_samples']}")
|
||||
# A success flag is never consumed; it is checked against independently observed integrity.
|
||||
integrity_ok = not any(issues[key] for key in ("schema", "provenance", "matrix", "accounting", "seal", "selection", "finite", "leakage", "metrics"))
|
||||
payload = {"evaluator_version": EVALUATOR_VERSION, "protocol_version": PROTOCOL_VERSION, "run_root": str(root), "pass": integrity_ok, "integrity_pass": integrity_ok, "workloads": workloads, "accounting": {**totals, "expected_pso_queries": TOTAL_PSO_QUERIES, "expected_candidate_samples": TOTAL_CANDIDATE_SAMPLES}, "issues": issues, "issue_counts": {key: len(value) for key, value in issues.items()}}
|
||||
return payload
|
||||
|
||||
|
||||
def build_cli_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Evaluate frozen post-training model-convergence artifacts")
|
||||
parser.add_argument("--run-root", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, default=None)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_cli_parser().parse_args(argv); payload = evaluate_run(args.run_root)
|
||||
destination = args.output or args.run_root / "evaluation.json"
|
||||
try: _atomic_json(destination, payload)
|
||||
except OSError as exc:
|
||||
print(f"evaluator output failed: {exc}", file=sys.stderr); return 2
|
||||
print(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||
return 0 if payload["pass"] else 1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BASE_SEEDS", "BOOTSTRAP_ALPHA", "BOOTSTRAP_RESAMPLES", "BOOTSTRAP_SEED", "CLASSIFICATION_WORKLOADS",
|
||||
"DETECTION_WORKLOAD", "EVALUATOR_VERSION", "ENSEMBLE_QUERIES", "TOTAL_CANDIDATE_SAMPLES", "TOTAL_PSO_QUERIES",
|
||||
"WORKLOADS", "classification_metrics", "detection_metrics", "evaluate_run", "main", "build_cli_parser",
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+95
-72
@@ -1,87 +1,110 @@
|
||||
# %%
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
from keras.datasets import fashion_mnist
|
||||
from keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPooling2D
|
||||
from keras.models import Sequential
|
||||
|
||||
from pso import optimizer
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
from pso import Optimizer
|
||||
from cli import add_pso_args, build_optimizer_kwargs
|
||||
|
||||
|
||||
def get_data():
|
||||
(x_train, y_train), (x_test, y_test) = fashion_mnist.load_data()
|
||||
def get_data(seed: int = 42):
|
||||
from sklearn.decomposition import PCA
|
||||
from torchvision.datasets import FashionMNIST
|
||||
|
||||
x_train, x_test = x_train / 255.0, x_test / 255.0
|
||||
x_train = x_train.reshape((60000, 28, 28, 1))
|
||||
x_test = x_test.reshape((10000, 28, 28, 1))
|
||||
train_dataset = FashionMNIST(root="./data", train=True, download=True)
|
||||
test_dataset = FashionMNIST(root="./data", train=False, download=True)
|
||||
|
||||
y_train, y_test = tf.one_hot(y_train, 10), tf.one_hot(y_test, 10)
|
||||
x_train_raw = (train_dataset.data[:3000].float() / 255.0).reshape(3000, -1).numpy()
|
||||
y_train = train_dataset.targets[:3000].long()
|
||||
|
||||
x_train, x_test = tf.convert_to_tensor(x_train), tf.convert_to_tensor(x_test)
|
||||
y_train, y_test = tf.convert_to_tensor(y_train), tf.convert_to_tensor(y_test)
|
||||
x_test_raw = (test_dataset.data[:1000].float() / 255.0).reshape(1000, -1).numpy()
|
||||
y_test = test_dataset.targets[:1000].long()
|
||||
|
||||
print(f"x_train : {x_train[0].shape} | y_train : {y_train[0].shape}")
|
||||
print(f"x_test : {x_test[0].shape} | y_test : {y_test[0].shape}")
|
||||
pca = PCA(n_components=32, whiten=True, random_state=seed)
|
||||
x_train_pca = pca.fit_transform(x_train_raw)
|
||||
x_test_pca = pca.transform(x_test_raw)
|
||||
|
||||
x_train = torch.tensor(x_train_pca, dtype=torch.float32)
|
||||
x_test = torch.tensor(x_test_pca, dtype=torch.float32)
|
||||
|
||||
print(f"x_train : {x_train.shape} | y_train : {y_train.shape}")
|
||||
print(f"x_test : {x_test.shape} | y_test : {y_test.shape}")
|
||||
|
||||
return x_train, y_train, x_test, y_test
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(
|
||||
Conv2D(32, kernel_size=(5, 5), activation="relu", input_shape=(28, 28, 1))
|
||||
def make_model(seed: int = 42):
|
||||
torch.manual_seed(seed)
|
||||
return nn.Linear(32, 10)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PSO Fashion-MNIST Benchmark Script")
|
||||
add_pso_args(
|
||||
parser,
|
||||
defaults={
|
||||
"method": "original",
|
||||
"initialization": "model_noise",
|
||||
"evaluation": "fixed_subset",
|
||||
"convergence": "particle_reset",
|
||||
"refinement": "adam",
|
||||
"n_particles": 30,
|
||||
"c0": None,
|
||||
"c1": None,
|
||||
"w_min": None,
|
||||
"w_max": None,
|
||||
"negative_swarm": 0.0,
|
||||
"mutation_swarm": 0.05,
|
||||
"particle_min": -3.0,
|
||||
"particle_max": 3.0,
|
||||
"velocity_limit_ratio": 0.1,
|
||||
"boundary_strategy": "reflect",
|
||||
"seed": 42,
|
||||
"epochs": 80,
|
||||
"batch_size": 1000,
|
||||
"fitness_size": 2000,
|
||||
"renewal": "loss",
|
||||
"output_dir": "output/fashion_mnist",
|
||||
"checkpoint_interval": 25,
|
||||
"refinement_epochs": 10,
|
||||
"refinement_lr": 0.001,
|
||||
},
|
||||
)
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Conv2D(64, kernel_size=(3, 3), activation="relu"))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Flatten())
|
||||
model.add(Dropout(0.25))
|
||||
model.add(Dense(256, activation="relu"))
|
||||
model.add(Dense(128, activation="relu"))
|
||||
model.add(Dense(10, activation="softmax"))
|
||||
args = parser.parse_args()
|
||||
|
||||
return model
|
||||
model = make_model(seed=args.seed)
|
||||
x_train, y_train, x_test, y_test = get_data(seed=args.seed)
|
||||
|
||||
fitness_size = args.fitness_size if args.evaluation == "fixed_subset" else None
|
||||
refinement_epochs = args.refinement_epochs if args.refinement == "adam" else 0
|
||||
|
||||
kwargs = build_optimizer_kwargs(
|
||||
args,
|
||||
model=model,
|
||||
loss=nn.CrossEntropyLoss(),
|
||||
task="multiclass",
|
||||
inertia_profile={"c0": 0.7, "c1": 0.5, "w_min": 0.1, "w_max": 0.8},
|
||||
)
|
||||
pso_fashion = Optimizer(**kwargs)
|
||||
|
||||
print(f"Optimizer device: {pso_fashion.device}")
|
||||
|
||||
best_score = pso_fashion.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=fitness_size,
|
||||
renewal=args.renewal,
|
||||
validation_data=(x_test, y_test),
|
||||
output_dir=args.output_dir,
|
||||
checkpoint_interval=25,
|
||||
save_info=True,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
|
||||
print(f"Done! Best score: {best_score}")
|
||||
|
||||
|
||||
# %%
|
||||
model = make_model()
|
||||
x_train, y_train, x_test, y_test = get_data()
|
||||
|
||||
|
||||
pso_mnist = optimizer(
|
||||
model,
|
||||
loss="categorical_crossentropy",
|
||||
n_particles=200,
|
||||
c0=0.7,
|
||||
c1=0.5,
|
||||
w_min=0.1,
|
||||
w_max=0.8,
|
||||
negative_swarm=0.0,
|
||||
mutation_swarm=0.05,
|
||||
convergence_reset=True,
|
||||
convergence_reset_patience=10,
|
||||
convergence_reset_monitor="loss",
|
||||
)
|
||||
|
||||
best_score = pso_mnist.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=1000,
|
||||
save_info=True,
|
||||
log=2,
|
||||
log_name="fashion_mnist",
|
||||
renewal="loss",
|
||||
check_point=25,
|
||||
batch_size=5000,
|
||||
)
|
||||
|
||||
print("Done!")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
from keras.models import Sequential
|
||||
from keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPooling2D
|
||||
from keras.datasets import mnist, fashion_mnist
|
||||
from keras.utils import to_categorical
|
||||
# from tensorflow.data.Dataset import from_tensor_slices
|
||||
import tensorflow as tf
|
||||
import os
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
|
||||
|
||||
gpus = tf.config.experimental.list_physical_devices("GPU")
|
||||
if gpus:
|
||||
try:
|
||||
tf.config.experimental.set_memory_growth(gpus[0], True)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
finally:
|
||||
del gpus
|
||||
|
||||
|
||||
def get_data():
|
||||
(x_train, y_train), (x_test, y_test) = fashion_mnist.load_data()
|
||||
print(f"y_train : {y_train[0]} | y_test : {y_test[0]}")
|
||||
|
||||
x_train, x_test = x_train / 255.0, x_test / 255.0
|
||||
x_train = x_train.reshape((60000, 28, 28, 1))
|
||||
x_test = x_test.reshape((10000, 28, 28, 1))
|
||||
|
||||
print(f"x_train : {x_train[0].shape} | y_train : {y_train[0].shape}")
|
||||
print(f"x_test : {x_test[0].shape} | y_test : {y_test[0].shape}")
|
||||
|
||||
return x_train, y_train, x_test, y_test
|
||||
|
||||
|
||||
class _batch_generator:
|
||||
def __init__(self, x, y, batch_size: int = 32):
|
||||
self.batch_size = batch_size
|
||||
self.index = 0
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.setBatchSize(batch_size)
|
||||
|
||||
def next(self):
|
||||
self.index += 1
|
||||
if self.index >= self.max_index:
|
||||
self.index = 0
|
||||
return self.dataset[self.index][0], self.dataset[self.index][1]
|
||||
|
||||
def getMaxIndex(self):
|
||||
return self.max_index
|
||||
|
||||
def getIndex(self):
|
||||
return self.index
|
||||
|
||||
def setIndex(self, index):
|
||||
self.index = index
|
||||
|
||||
def getBatchSize(self):
|
||||
return self.batch_size
|
||||
|
||||
def setBatchSize(self, batch_size):
|
||||
self.batch_size = batch_size
|
||||
self.dataset = list(
|
||||
tf.data.Dataset.from_tensor_slices(
|
||||
(self.x, self.y)).batch(batch_size)
|
||||
)
|
||||
self.max_index = len(self.dataset)
|
||||
|
||||
def getDataset(self):
|
||||
return self.dataset
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(
|
||||
Conv2D(32, kernel_size=(5, 5), activation="sigmoid",
|
||||
input_shape=(28, 28, 1))
|
||||
)
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Conv2D(64, kernel_size=(3, 3), activation="sigmoid"))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Flatten())
|
||||
model.add(Dropout(0.25))
|
||||
model.add(Dense(128, activation="sigmoid"))
|
||||
model.add(Dense(10, activation="softmax"))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
model = make_model()
|
||||
x_train, y_train, x_test, y_test = get_data()
|
||||
print(x_train.shape)
|
||||
y_train = tf.one_hot(y_train, 10)
|
||||
y_test = tf.one_hot(y_test, 10)
|
||||
|
||||
dataset = _batch_generator(x_train, y_train, 32)
|
||||
|
||||
model.compile(optimizer="adam", loss="mse", metrics=["accuracy"])
|
||||
|
||||
count = 0
|
||||
|
||||
while count < 100:
|
||||
x_batch, y_batch = dataset.next()
|
||||
count += 1
|
||||
print("Training model...")
|
||||
model.fit(x_batch, y_batch, epochs=1, batch_size=1, verbose=1)
|
||||
|
||||
print(count)
|
||||
print(f"Max index : {dataset.getMaxIndex()}")
|
||||
|
||||
print("Evaluating model...")
|
||||
model.evaluate(x_test, y_test, verbose=2)
|
||||
|
||||
weights = model.get_weights()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Fashion-MNIST dataset gradient baseline (PyTorch standard backprop optimizer, non-PSO)."""
|
||||
|
||||
import copy
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
|
||||
class FashionMNISTModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.conv1 = nn.Conv2d(1, 32, kernel_size=5)
|
||||
self.sig1 = nn.Sigmoid()
|
||||
self.pool1 = nn.MaxPool2d(2, 2)
|
||||
|
||||
self.conv2 = nn.Conv2d(32, 64, kernel_size=3)
|
||||
self.sig2 = nn.Sigmoid()
|
||||
self.pool2 = nn.MaxPool2d(2, 2)
|
||||
|
||||
self.drop = nn.Dropout(0.25)
|
||||
self.fc1 = nn.Linear(64 * 5 * 5, 128)
|
||||
self.sig3 = nn.Sigmoid()
|
||||
self.fc2 = nn.Linear(128, 10)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.pool1(self.sig1(self.conv1(x)))
|
||||
x = self.pool2(self.sig2(self.conv2(x)))
|
||||
x = torch.flatten(x, 1)
|
||||
x = self.drop(x)
|
||||
x = self.sig3(self.fc1(x))
|
||||
x = self.fc2(x)
|
||||
return x
|
||||
|
||||
|
||||
def get_data(download: bool = True):
|
||||
from torchvision import datasets, transforms
|
||||
|
||||
transform = transforms.ToTensor()
|
||||
train_dataset = datasets.FashionMNIST(
|
||||
root="./data", train=True, transform=transform, download=download
|
||||
)
|
||||
test_dataset = datasets.FashionMNIST(
|
||||
root="./data", train=False, transform=transform, download=download
|
||||
)
|
||||
return train_dataset, test_dataset
|
||||
|
||||
|
||||
def get_device() -> torch.device:
|
||||
if (
|
||||
hasattr(torch.backends, "mps")
|
||||
and torch.backends.mps.is_built()
|
||||
and torch.backends.mps.is_available()
|
||||
):
|
||||
return torch.device("mps")
|
||||
elif torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def main():
|
||||
torch.manual_seed(42)
|
||||
np.random.seed(42)
|
||||
|
||||
device = get_device()
|
||||
print(f"Selected device: {device}")
|
||||
|
||||
train_dataset, test_dataset = get_data(download=True)
|
||||
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
|
||||
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)
|
||||
|
||||
model = FashionMNISTModel().to(device)
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
best_val_loss = float("inf")
|
||||
best_state = None
|
||||
|
||||
for epoch in range(10):
|
||||
model.train()
|
||||
for bx, by in train_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
optimizer.zero_grad()
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
model.eval()
|
||||
val_loss = 0.0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in test_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
val_loss += loss.item() * bx.size(0)
|
||||
total += bx.size(0)
|
||||
|
||||
val_loss /= total
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
best_state = copy.deepcopy(model.state_dict())
|
||||
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
test_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in test_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
test_loss += loss.item() * bx.size(0)
|
||||
preds = out.argmax(dim=1)
|
||||
correct += (preds == by).sum().item()
|
||||
total += bx.size(0)
|
||||
|
||||
test_loss /= total
|
||||
test_acc = correct / total
|
||||
print(f"Final test loss: {test_loss:.4f}, accuracy: {test_acc:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,707 @@
|
||||
"""
|
||||
Full-MNIST 60k/10k Particle Swarm Optimization Trajectory Analysis
|
||||
|
||||
Evaluates the selected Adaptive Moment PSO configuration on full official MNIST (60,000 train / 10,000 test)
|
||||
across 120 particles for 240 continuous epochs, scoring all 60,000 training examples for every particle
|
||||
at every epoch.
|
||||
|
||||
Predeclared Diagnostic Criteria:
|
||||
1. Post-80 Training Convergence: Mean training loss falls >= 1% from epoch 80 to epoch 240.
|
||||
2. Epoch 240 Test Gain: Mean test accuracy at epoch 240 rises >= 1 percentage point vs epoch 80.
|
||||
3. Late Plateau (200->240): Training loss improvement < 1% AND absolute test accuracy change < 0.5 percentage points.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
# Ensure test/ directory is in sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from benchmark_suite import (
|
||||
calc_stats,
|
||||
extract_plugin_metadata,
|
||||
compute_data_fingerprint,
|
||||
compute_model_fingerprint,
|
||||
get_hardware_provenance,
|
||||
make_mnist_model,
|
||||
resolve_execution_device,
|
||||
save_json_atomic,
|
||||
sync_device,
|
||||
)
|
||||
from pso import Optimizer, __version__ as pso_version
|
||||
from reproduce_scaling import validate_and_load_baseline
|
||||
|
||||
FULL_MNIST_PROTOCOL_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def prepare_full_mnist_data() -> Tuple[
|
||||
torch.Tensor,
|
||||
torch.Tensor,
|
||||
torch.Tensor,
|
||||
torch.Tensor,
|
||||
str,
|
||||
Dict[str, Any],
|
||||
]:
|
||||
"""
|
||||
Load official torchvision MNIST full train (60,000) and test (10,000).
|
||||
Normalize pixels, flatten to 784, fit PCA(n_components=32, whiten=True, random_state=42)
|
||||
on train only, transform test. Validate 60,000/10,000 sample counts and label range [0, 9].
|
||||
"""
|
||||
from torchvision.datasets import MNIST
|
||||
from sklearn.decomposition import PCA
|
||||
|
||||
cache_dir = Path("result/cache")
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
train_dataset = MNIST(root=str(cache_dir), train=True, download=True)
|
||||
test_dataset = MNIST(root=str(cache_dir), train=False, download=True)
|
||||
|
||||
n_train = len(train_dataset.data)
|
||||
n_test = len(test_dataset.data)
|
||||
if n_train != 60000:
|
||||
raise ValueError(f"Expected 60,000 training samples; got {n_train}")
|
||||
if n_test != 10000:
|
||||
raise ValueError(f"Expected 10,000 test samples; got {n_test}")
|
||||
|
||||
y_train_60000 = train_dataset.targets.long()
|
||||
y_test_10000 = test_dataset.targets.long()
|
||||
|
||||
min_tr_lbl, max_tr_lbl = int(y_train_60000.min()), int(y_train_60000.max())
|
||||
min_te_lbl, max_te_lbl = int(y_test_10000.min()), int(y_test_10000.max())
|
||||
|
||||
if min_tr_lbl != 0 or max_tr_lbl != 9:
|
||||
raise ValueError(f"Train label range must be [0, 9]; got [{min_tr_lbl}, {max_tr_lbl}]")
|
||||
if min_te_lbl != 0 or max_te_lbl != 9:
|
||||
raise ValueError(f"Test label range must be [0, 9]; got [{min_te_lbl}, {max_te_lbl}]")
|
||||
|
||||
x_train_raw = (train_dataset.data.float() / 255.0).reshape(60000, -1).numpy()
|
||||
x_test_raw = (test_dataset.data.float() / 255.0).reshape(10000, -1).numpy()
|
||||
|
||||
pca = PCA(n_components=32, whiten=True, random_state=42)
|
||||
x_full_tr = torch.tensor(pca.fit_transform(x_train_raw), dtype=torch.float32)
|
||||
x_full_test = torch.tensor(pca.transform(x_test_raw), dtype=torch.float32)
|
||||
|
||||
data_fp = compute_data_fingerprint(x_full_tr, x_full_test, y_train_60000, y_test_10000)
|
||||
pca_provenance = {
|
||||
"n_components": 32,
|
||||
"whiten": True,
|
||||
"random_state": 42,
|
||||
"fit_scope": "official_train_split_60000_only",
|
||||
"train_samples_fit": 60000,
|
||||
"test_samples_transformed": 10000,
|
||||
"explained_variance_ratio_sum": float(np.sum(pca.explained_variance_ratio_)),
|
||||
}
|
||||
return (
|
||||
x_full_tr,
|
||||
y_train_60000,
|
||||
x_full_test,
|
||||
y_test_10000,
|
||||
data_fp,
|
||||
pca_provenance,
|
||||
)
|
||||
|
||||
|
||||
def render_trajectory_plot(
|
||||
checkpoint_stats: Dict[int, Dict[str, Any]],
|
||||
subset_comparison: Dict[str, Any],
|
||||
figure_path: Path,
|
||||
):
|
||||
"""
|
||||
Render a readable two-panel mean +/- SD trajectory plot for training loss and test accuracy,
|
||||
with optional dashed subset-study mean comparison and epoch 80 marker.
|
||||
"""
|
||||
figure_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
eps = sorted(list(checkpoint_stats.keys()))
|
||||
tr_loss_mean = [checkpoint_stats[ep]["train_loss"]["mean"] for ep in eps]
|
||||
tr_loss_std = [checkpoint_stats[ep]["train_loss"]["std"] for ep in eps]
|
||||
te_acc_mean = [checkpoint_stats[ep]["test_acc"]["mean"] for ep in eps]
|
||||
te_acc_std = [checkpoint_stats[ep]["test_acc"]["std"] for ep in eps]
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
|
||||
|
||||
# Panel 1: Training Loss
|
||||
ax1.plot(eps, tr_loss_mean, "o-", color="tab:blue", linewidth=2, label="Full MNIST Train Loss (Mean)")
|
||||
ax1.fill_between(
|
||||
eps,
|
||||
np.array(tr_loss_mean) - np.array(tr_loss_std),
|
||||
np.array(tr_loss_mean) + np.array(tr_loss_std),
|
||||
color="tab:blue",
|
||||
alpha=0.2,
|
||||
label="±1 SD",
|
||||
)
|
||||
|
||||
if "matching_epoch_deltas" in subset_comparison:
|
||||
sub_deltas = subset_comparison["matching_epoch_deltas"]
|
||||
sub_eps = sorted([ep for ep in eps if str(ep) in sub_deltas or ep in sub_deltas])
|
||||
if sub_eps:
|
||||
sub_tr_loss = [sub_deltas.get(str(ep), sub_deltas.get(ep, {}))["subset_study_train_loss_mean"] for ep in sub_eps]
|
||||
ax1.plot(sub_eps, sub_tr_loss, "--", color="gray", alpha=0.8, label="Subset Study (2k sample) Train Loss")
|
||||
|
||||
if 80 in eps:
|
||||
ax1.axvline(80, color="red", linestyle=":", label="Epoch 80 Marker")
|
||||
|
||||
ax1.set_ylabel("Training Loss")
|
||||
ax1.set_title("Full MNIST (60,000 Train / 10,000 Test) PSO Trajectory (120 Particles, AM)")
|
||||
ax1.grid(True, linestyle="--", alpha=0.5)
|
||||
ax1.legend(loc="upper right")
|
||||
|
||||
# Panel 2: Test Accuracy
|
||||
ax2.plot(eps, te_acc_mean, "s-", color="tab:green", linewidth=2, label="Full MNIST Test Accuracy (Mean)")
|
||||
ax2.fill_between(
|
||||
eps,
|
||||
np.array(te_acc_mean) - np.array(te_acc_std),
|
||||
np.array(te_acc_mean) + np.array(te_acc_std),
|
||||
color="tab:green",
|
||||
alpha=0.2,
|
||||
label="±1 SD",
|
||||
)
|
||||
|
||||
if "matching_epoch_deltas" in subset_comparison:
|
||||
sub_deltas = subset_comparison["matching_epoch_deltas"]
|
||||
sub_eps = sorted([ep for ep in eps if str(ep) in sub_deltas or ep in sub_deltas])
|
||||
if sub_eps:
|
||||
sub_te_acc = [sub_deltas.get(str(ep), sub_deltas.get(ep, {}))["subset_study_test_acc_mean"] for ep in sub_eps]
|
||||
ax2.plot(sub_eps, sub_te_acc, "--", color="gray", alpha=0.8, label="Subset Study (2k sample) Test Acc")
|
||||
|
||||
if 80 in eps:
|
||||
ax2.axvline(80, color="red", linestyle=":", label="Epoch 80 Marker")
|
||||
|
||||
ax2.set_xlabel("Epoch")
|
||||
ax2.set_ylabel("Test Accuracy")
|
||||
ax2.grid(True, linestyle="--", alpha=0.5)
|
||||
ax2.legend(loc="lower right")
|
||||
|
||||
plt.tight_layout()
|
||||
fig.savefig(figure_path, dpi=300)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def run_full_mnist_study(
|
||||
baseline_path: Path,
|
||||
subset_study_path: Path,
|
||||
output_json_path: Path,
|
||||
output_csv_path: Path,
|
||||
figure_path: Path,
|
||||
device_str: str = "auto",
|
||||
epochs: int = 240,
|
||||
seeds: List[int] = None,
|
||||
) -> bool:
|
||||
if seeds is None:
|
||||
seeds = [71, 72, 73, 74, 75]
|
||||
if not isinstance(epochs, int) or isinstance(epochs, bool) or epochs <= 0:
|
||||
raise ValueError("epochs must be a positive integer")
|
||||
if epochs % 20 != 0:
|
||||
raise ValueError("epochs must be a multiple of 20 so every final checkpoint exists")
|
||||
if not seeds or len(seeds) != len(set(seeds)):
|
||||
raise ValueError("seeds must be a non-empty list of unique integers")
|
||||
if any(isinstance(seed, bool) or not isinstance(seed, int) or seed < 0 for seed in seeds):
|
||||
raise ValueError("every seed must be a non-negative integer")
|
||||
|
||||
|
||||
dev_input = None if device_str == "auto" else device_str
|
||||
device = resolve_execution_device(dev_input)
|
||||
hw_provenance = get_hardware_provenance(device)
|
||||
|
||||
# 1. Validate baseline JSON & load winner config
|
||||
baseline_data, _baseline_records, winner_cfg, _expected_fp = validate_and_load_baseline(
|
||||
baseline_path
|
||||
)
|
||||
|
||||
if baseline_data.get("device") != device.type:
|
||||
raise ValueError(
|
||||
f"Baseline device mismatch: baseline requires {baseline_data.get('device')!r}, got {device.type!r}"
|
||||
)
|
||||
if baseline_data.get("pso_version") != pso_version:
|
||||
raise ValueError(
|
||||
f"PSO version mismatch: baseline requires {baseline_data.get('pso_version')!r}, got {pso_version!r}"
|
||||
)
|
||||
if baseline_data.get("torch_version") != torch.__version__:
|
||||
raise ValueError(
|
||||
f"Torch version mismatch: baseline requires {baseline_data.get('torch_version')!r}, got {torch.__version__!r}"
|
||||
)
|
||||
|
||||
# 2. Prepare Full MNIST PCA Data (60k train / 10k test)
|
||||
(
|
||||
x_full_tr,
|
||||
y_train_60000,
|
||||
x_full_test,
|
||||
y_test_10000,
|
||||
data_fp,
|
||||
pca_provenance,
|
||||
) = prepare_full_mnist_data()
|
||||
|
||||
opt_kwargs = winner_cfg.to_optimizer_kwargs(quick=False)
|
||||
opt_kwargs["evaluation"] = "full"
|
||||
opt_kwargs.pop("fitness_size", None)
|
||||
|
||||
n_particles = 120
|
||||
target_epochs = epochs
|
||||
batch_size = 60000
|
||||
checkpoint_interval = 20
|
||||
|
||||
ckpt_epochs = [ep for ep in range(checkpoint_interval, target_epochs + 1, checkpoint_interval)]
|
||||
if not ckpt_epochs or ckpt_epochs[-1] != target_epochs:
|
||||
if target_epochs not in ckpt_epochs:
|
||||
ckpt_epochs.append(target_epochs)
|
||||
ckpt_epochs = sorted(list(set(ckpt_epochs)))
|
||||
|
||||
runs: List[Dict[str, Any]] = []
|
||||
flat_csv_rows: List[Dict[str, Any]] = []
|
||||
plugin_meta: Dict[str, Any] | None = None
|
||||
|
||||
# 3. Seed Runs
|
||||
for seed in sorted(seeds):
|
||||
# Warmup Phase (2 epochs full eval)
|
||||
warmup_model = make_mnist_model(seed=seed)
|
||||
warmup_loss = nn.CrossEntropyLoss()
|
||||
warmup_opt = Optimizer(
|
||||
model=warmup_model,
|
||||
loss=warmup_loss,
|
||||
task="multiclass",
|
||||
n_particles=n_particles,
|
||||
seed=seed,
|
||||
device=device,
|
||||
**opt_kwargs,
|
||||
)
|
||||
warmup_opt.fit(
|
||||
x_full_tr,
|
||||
y_train_60000,
|
||||
epochs=2,
|
||||
batch_size=batch_size,
|
||||
renewal="loss",
|
||||
)
|
||||
sync_device(device)
|
||||
del warmup_opt, warmup_model, warmup_loss
|
||||
|
||||
# Timed Continuous Trajectory
|
||||
model = make_mnist_model(seed=seed)
|
||||
model_fp = compute_model_fingerprint(model)
|
||||
loss_inst = nn.CrossEntropyLoss()
|
||||
opt = Optimizer(
|
||||
model=model,
|
||||
loss=loss_inst,
|
||||
task="multiclass",
|
||||
n_particles=n_particles,
|
||||
seed=seed,
|
||||
device=device,
|
||||
**opt_kwargs,
|
||||
)
|
||||
current_plugin_meta = extract_plugin_metadata(opt)
|
||||
if plugin_meta is None:
|
||||
plugin_meta = current_plugin_meta
|
||||
elif current_plugin_meta != plugin_meta:
|
||||
raise RuntimeError("Resolved plugin metadata changed across seeds")
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir_str:
|
||||
output_dir = Path(temp_dir_str)
|
||||
sync_device(device)
|
||||
t0 = time.perf_counter()
|
||||
train_loss_final, train_acc_final, train_mse_final = opt.fit(
|
||||
x_full_tr,
|
||||
y_train_60000,
|
||||
epochs=target_epochs,
|
||||
batch_size=batch_size,
|
||||
renewal="loss",
|
||||
output_dir=output_dir,
|
||||
log_format="csv",
|
||||
checkpoint_interval=checkpoint_interval,
|
||||
)
|
||||
sync_device(device)
|
||||
t1 = time.perf_counter()
|
||||
fit_time_sec = t1 - t0
|
||||
|
||||
if not (math.isfinite(train_loss_final) and math.isfinite(train_acc_final) and math.isfinite(train_mse_final)):
|
||||
raise RuntimeError(f"Seed {seed} final metrics non-finite: loss={train_loss_final}, acc={train_acc_final}")
|
||||
|
||||
history_csv_path = output_dir / "history.csv"
|
||||
epoch_history = []
|
||||
if history_csv_path.exists():
|
||||
with open(history_csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
epoch_history.append({
|
||||
"epoch": int(row["epoch"]),
|
||||
"loss": float(row["loss"]),
|
||||
"accuracy": float(row["accuracy"]),
|
||||
"mse": float(row["mse"]),
|
||||
})
|
||||
|
||||
prev_best_loss = float("inf")
|
||||
improvement_count = 0
|
||||
last_improvement_epoch = 1
|
||||
for row in epoch_history:
|
||||
ep_num = row["epoch"]
|
||||
l_val = row["loss"]
|
||||
if l_val < prev_best_loss:
|
||||
improvement_count += 1
|
||||
last_improvement_epoch = ep_num
|
||||
prev_best_loss = l_val
|
||||
|
||||
checkpoints: List[Dict[str, Any]] = []
|
||||
ckpt_dir = output_dir / "checkpoints"
|
||||
for ep in ckpt_epochs:
|
||||
ckpt_path = ckpt_dir / f"epoch-{ep}.pt"
|
||||
if not ckpt_path.exists():
|
||||
raise FileNotFoundError(f"Missing checkpoint file: {ckpt_path}")
|
||||
payload = torch.load(ckpt_path, map_location=device, weights_only=True)
|
||||
ckpt_tr_loss, ckpt_tr_acc, ckpt_tr_mse = payload["score"]
|
||||
if not (math.isfinite(ckpt_tr_loss) and math.isfinite(ckpt_tr_acc) and math.isfinite(ckpt_tr_mse)):
|
||||
raise RuntimeError(f"Seed {seed} epoch {ep} score non-finite: {payload['score']}")
|
||||
|
||||
opt.eval_model.load_state_dict(payload["model_state_dict"])
|
||||
opt._global_best_weights = opt.codec.encode(opt.eval_model)
|
||||
test_loss, test_acc, test_mse = opt.evaluate(x_full_test, y_test_10000)
|
||||
|
||||
if not (math.isfinite(test_loss) and math.isfinite(test_acc) and math.isfinite(test_mse)):
|
||||
raise RuntimeError(f"Seed {seed} epoch {ep} test metrics non-finite: loss={test_loss}, acc={test_acc}")
|
||||
|
||||
ckpt_record = {
|
||||
"epoch": ep,
|
||||
"train_loss": float(ckpt_tr_loss),
|
||||
"train_acc": float(ckpt_tr_acc),
|
||||
"train_mse": float(ckpt_tr_mse),
|
||||
"test_loss": float(test_loss),
|
||||
"test_acc": float(test_acc),
|
||||
"test_mse": float(test_mse),
|
||||
}
|
||||
checkpoints.append(ckpt_record)
|
||||
|
||||
flat_csv_rows.append({
|
||||
"seed": seed,
|
||||
"epoch": ep,
|
||||
"train_loss": float(ckpt_tr_loss),
|
||||
"train_acc": float(ckpt_tr_acc),
|
||||
"train_mse": float(ckpt_tr_mse),
|
||||
"test_loss": float(test_loss),
|
||||
"test_acc": float(test_acc),
|
||||
"test_mse": float(test_mse),
|
||||
"fit_time_sec": round(fit_time_sec, 4),
|
||||
})
|
||||
|
||||
runs.append({
|
||||
"seed": seed,
|
||||
"model_fingerprint": model_fp,
|
||||
"fit_time_sec": round(fit_time_sec, 4),
|
||||
"improvement_count": improvement_count,
|
||||
"last_improvement_epoch": last_improvement_epoch,
|
||||
"checkpoints": checkpoints,
|
||||
"completed": True,
|
||||
"error": None,
|
||||
"plugins": current_plugin_meta,
|
||||
"epoch_history": epoch_history,
|
||||
})
|
||||
|
||||
# 4. Aggregations & Statistics
|
||||
checkpoint_stats: Dict[int, Dict[str, Any]] = {}
|
||||
for ep in ckpt_epochs:
|
||||
ep_train_losses = [next(c["train_loss"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_train_accs = [next(c["train_acc"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_train_mses = [next(c["train_mse"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
|
||||
ep_test_losses = [next(c["test_loss"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_test_accs = [next(c["test_acc"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
ep_test_mses = [next(c["test_mse"] for c in r["checkpoints"] if c["epoch"] == ep) for r in runs]
|
||||
|
||||
checkpoint_stats[ep] = {
|
||||
"train_loss": calc_stats(ep_train_losses),
|
||||
"train_acc": calc_stats(ep_train_accs),
|
||||
"train_mse": calc_stats(ep_train_mses),
|
||||
"test_loss": calc_stats(ep_test_losses),
|
||||
"test_acc": calc_stats(ep_test_accs),
|
||||
"test_mse": calc_stats(ep_test_mses),
|
||||
}
|
||||
|
||||
# Endpoint paired deltas (80->240 and 200->240 if available)
|
||||
paired_deltas: Dict[str, Any] = {}
|
||||
paired_endpoint_deltas_by_seed: List[Dict[str, Any]] = []
|
||||
|
||||
has_80 = 80 in checkpoint_stats
|
||||
has_200 = 200 in checkpoint_stats
|
||||
has_240 = 240 in checkpoint_stats
|
||||
|
||||
if has_80 and has_240:
|
||||
deltas_80_to_240_train_rel = []
|
||||
deltas_80_to_240_test_acc = []
|
||||
for r in runs:
|
||||
c80 = next(c for c in r["checkpoints"] if c["epoch"] == 80)
|
||||
c240 = next(c for c in r["checkpoints"] if c["epoch"] == 240)
|
||||
tl80, tl240 = c80["train_loss"], c240["train_loss"]
|
||||
ta80, ta240 = c80["test_acc"], c240["test_acc"]
|
||||
|
||||
rel_red = (tl80 - tl240) / tl80 if tl80 > 0 else 0.0
|
||||
acc_delta = ta240 - ta80
|
||||
deltas_80_to_240_train_rel.append(rel_red)
|
||||
deltas_80_to_240_test_acc.append(acc_delta)
|
||||
|
||||
paired_rec = {
|
||||
"seed": r["seed"],
|
||||
"train_loss_relative_reduction_80_to_240": rel_red,
|
||||
"test_accuracy_delta_80_to_240": acc_delta,
|
||||
}
|
||||
if has_200:
|
||||
c200 = next(c for c in r["checkpoints"] if c["epoch"] == 200)
|
||||
tl200, ta200 = c200["train_loss"], c200["test_acc"]
|
||||
rel_red_200 = (tl200 - tl240) / tl200 if tl200 > 0 else 0.0
|
||||
acc_delta_200 = ta240 - ta200
|
||||
paired_rec["train_loss_relative_reduction_200_to_240"] = rel_red_200
|
||||
paired_rec["test_accuracy_delta_200_to_240"] = acc_delta_200
|
||||
paired_endpoint_deltas_by_seed.append(paired_rec)
|
||||
|
||||
paired_deltas["80_to_240"] = {
|
||||
"train_loss_rel_reduction": calc_stats(deltas_80_to_240_train_rel),
|
||||
"test_acc_delta": calc_stats(deltas_80_to_240_test_acc),
|
||||
}
|
||||
|
||||
if has_200 and has_240:
|
||||
deltas_200_to_240_train_rel = []
|
||||
deltas_200_to_240_test_acc = []
|
||||
deltas_200_to_240_test_acc_abs = []
|
||||
for r in runs:
|
||||
c200 = next(c for c in r["checkpoints"] if c["epoch"] == 200)
|
||||
c240 = next(c for c in r["checkpoints"] if c["epoch"] == 240)
|
||||
tl200, tl240 = c200["train_loss"], c240["train_loss"]
|
||||
ta200, ta240 = c200["test_acc"], c240["test_acc"]
|
||||
rel_red_200 = (tl200 - tl240) / tl200 if tl200 > 0 else 0.0
|
||||
acc_delta_200 = ta240 - ta200
|
||||
acc_abs_200 = abs(ta240 - ta200)
|
||||
deltas_200_to_240_train_rel.append(rel_red_200)
|
||||
deltas_200_to_240_test_acc.append(acc_delta_200)
|
||||
deltas_200_to_240_test_acc_abs.append(acc_abs_200)
|
||||
|
||||
paired_deltas["200_to_240"] = {
|
||||
"train_loss_rel_reduction": calc_stats(deltas_200_to_240_train_rel),
|
||||
"test_acc_delta": calc_stats(deltas_200_to_240_test_acc),
|
||||
"test_acc_abs_change": calc_stats(deltas_200_to_240_test_acc_abs),
|
||||
}
|
||||
|
||||
# Predeclared Diagnostics
|
||||
diagnostics = {}
|
||||
if has_80 and has_240:
|
||||
mean_tl80 = checkpoint_stats[80]["train_loss"]["mean"]
|
||||
mean_tl240 = checkpoint_stats[240]["train_loss"]["mean"]
|
||||
mean_ta80 = checkpoint_stats[80]["test_acc"]["mean"]
|
||||
mean_ta240 = checkpoint_stats[240]["test_acc"]["mean"]
|
||||
|
||||
post80_train_improvement_pct = (mean_tl80 - mean_tl240) / mean_tl80 if mean_tl80 > 0 else 0.0
|
||||
epoch240_test_gain = mean_ta240 - mean_ta80
|
||||
|
||||
diagnostics["post80_train_improvement_pct"] = round(post80_train_improvement_pct, 6)
|
||||
diagnostics["post80_train_improvement_passed"] = bool(post80_train_improvement_pct >= 0.01)
|
||||
|
||||
diagnostics["epoch240_test_gain"] = round(epoch240_test_gain, 6)
|
||||
diagnostics["epoch240_test_gain_passed"] = bool(epoch240_test_gain >= 0.01)
|
||||
|
||||
if has_200:
|
||||
mean_tl200 = checkpoint_stats[200]["train_loss"]["mean"]
|
||||
mean_ta200 = checkpoint_stats[200]["test_acc"]["mean"]
|
||||
late_rel_red = (mean_tl200 - mean_tl240) / mean_tl200 if mean_tl200 > 0 else 0.0
|
||||
late_acc_abs = abs(mean_ta240 - mean_ta200)
|
||||
|
||||
is_late_plateau = bool(late_rel_red < 0.01 and late_acc_abs < 0.005)
|
||||
diagnostics["late_plateau_200_240"] = is_late_plateau
|
||||
diagnostics["late_plateau_train_loss_rel_reduction_200_240"] = round(late_rel_red, 6)
|
||||
diagnostics["late_plateau_test_acc_abs_change_200_240"] = round(late_acc_abs, 6)
|
||||
|
||||
# 5. Descriptive Comparison vs Subset Study
|
||||
subset_comparison: Dict[str, Any] = {}
|
||||
if subset_study_path.exists():
|
||||
try:
|
||||
with open(subset_study_path, "r", encoding="utf-8") as f:
|
||||
sub_json_data = json.load(f)
|
||||
sub_ckpt_stats = sub_json_data.get("summary", {}).get("checkpoint_stats", {})
|
||||
matching_stats = {}
|
||||
for ep in ckpt_epochs:
|
||||
ep_str = str(ep)
|
||||
if ep_str in sub_ckpt_stats:
|
||||
sub_e = sub_ckpt_stats[ep_str]
|
||||
full_tr_l = checkpoint_stats[ep]["train_loss"]["mean"]
|
||||
sub_tr_l = sub_e["train_loss"]["mean"]
|
||||
full_te_a = checkpoint_stats[ep]["test_acc"]["mean"]
|
||||
sub_te_a = sub_e["test_acc"]["mean"]
|
||||
|
||||
matching_stats[ep_str] = {
|
||||
"full_mnist_train_loss_mean": full_tr_l,
|
||||
"subset_study_train_loss_mean": sub_tr_l,
|
||||
"train_loss_delta_full_minus_subset": round(full_tr_l - sub_tr_l, 6),
|
||||
"full_mnist_test_acc_mean": full_te_a,
|
||||
"subset_study_test_acc_mean": sub_te_a,
|
||||
"test_acc_delta_full_minus_subset": round(full_te_a - sub_te_a, 6),
|
||||
}
|
||||
|
||||
subset_comparison = {
|
||||
"subset_study_path": str(subset_study_path),
|
||||
"disclaimer": (
|
||||
"The full and subset studies optimize different training objectives and the evaluation "
|
||||
"plugin changes RNG consumption (all 60,000 train samples versus a fixed 2,000-sample "
|
||||
"fitness subset). Matching-seed and matching-epoch comparisons are descriptive, not an "
|
||||
"exact paired causal isolation."
|
||||
),
|
||||
"matching_epoch_deltas": matching_stats,
|
||||
}
|
||||
except Exception as err:
|
||||
subset_comparison = {"error": f"Failed to parse subset study JSON: {err}"}
|
||||
|
||||
# 6. Save Artifacts
|
||||
# JSON output
|
||||
out_dict = {
|
||||
"full_mnist_protocol_version": FULL_MNIST_PROTOCOL_VERSION,
|
||||
"pso_version": pso_version,
|
||||
"torch_version": torch.__version__,
|
||||
"hardware": hw_provenance,
|
||||
"device": device.type,
|
||||
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"baseline_path": str(baseline_path),
|
||||
"subset_study_path": str(subset_study_path),
|
||||
"sample_counts": {
|
||||
"train_samples": 60000,
|
||||
"test_samples": 10000,
|
||||
},
|
||||
"pca_provenance": pca_provenance,
|
||||
"data_fingerprint": data_fp,
|
||||
"fitness_evaluation_contract": {
|
||||
"selector": "full",
|
||||
"fitness_size": None,
|
||||
"train_samples_per_particle_per_epoch": 60000,
|
||||
"particle_evaluations": n_particles * target_epochs * len(seeds),
|
||||
"particle_sample_evaluations": (
|
||||
n_particles * target_epochs * len(seeds) * 60000
|
||||
),
|
||||
"test_samples_per_checkpoint": 10000,
|
||||
},
|
||||
"candidate_label": winner_cfg.candidate_label,
|
||||
"config": {
|
||||
**opt_kwargs,
|
||||
"n_particles": n_particles,
|
||||
"epochs": target_epochs,
|
||||
"batch_size": batch_size,
|
||||
"renewal": "loss",
|
||||
"checkpoint_interval": checkpoint_interval,
|
||||
},
|
||||
"plugins": plugin_meta,
|
||||
"timing_scope": "fit_only_after_full-evaluation_two-epoch_warmup",
|
||||
"diagnostics": diagnostics,
|
||||
"descriptive_subset_comparison": subset_comparison,
|
||||
"summary": {
|
||||
"epochs": ckpt_epochs,
|
||||
"checkpoint_stats": {str(k): v for k, v in checkpoint_stats.items()},
|
||||
"paired_deltas": paired_deltas,
|
||||
"paired_endpoint_deltas_by_seed": paired_endpoint_deltas_by_seed,
|
||||
},
|
||||
"runs": runs,
|
||||
"completed": True,
|
||||
"valid": True,
|
||||
"error": None,
|
||||
}
|
||||
save_json_atomic(out_dict, output_json_path)
|
||||
|
||||
# CSV output
|
||||
output_csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = [
|
||||
"seed",
|
||||
"epoch",
|
||||
"train_loss",
|
||||
"train_acc",
|
||||
"train_mse",
|
||||
"test_loss",
|
||||
"test_acc",
|
||||
"test_mse",
|
||||
"fit_time_sec",
|
||||
]
|
||||
with open(output_csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for r in flat_csv_rows:
|
||||
writer.writerow(r)
|
||||
|
||||
# Plot output
|
||||
render_trajectory_plot(checkpoint_stats, subset_comparison, figure_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Full-MNIST 60k/10k Particle Swarm Optimization Trajectory Analysis"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_tuning.json"),
|
||||
help="Path to baseline tuning JSON (default: benchmark_results/pso_v4_tuning.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--subset-study-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_epoch_convergence.json"),
|
||||
help="Path to subset epoch convergence JSON (default: benchmark_results/pso_v4_epoch_convergence.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_full_mnist.json"),
|
||||
help="Path to output JSON (default: benchmark_results/pso_v4_full_mnist.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-csv",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_full_mnist.csv"),
|
||||
help="Path to output CSV (default: benchmark_results/pso_v4_full_mnist.csv)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--figure",
|
||||
type=Path,
|
||||
default=Path("history_plt/pso_v4_full_mnist.png"),
|
||||
help="Path to output plot figure PNG (default: history_plt/pso_v4_full_mnist.png)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default="auto",
|
||||
help="Execution device: auto, mps, cpu, cuda (default: auto)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs",
|
||||
type=int,
|
||||
default=240,
|
||||
help="Number of PSO training epochs (default: 240)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seeds",
|
||||
type=str,
|
||||
default="71,72,73,74,75",
|
||||
help="Comma-separated random seeds (default: 71,72,73,74,75)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
seed_list = [int(s.strip()) for s in args.seeds.split(",") if s.strip()]
|
||||
|
||||
run_full_mnist_study(
|
||||
baseline_path=args.baseline_json,
|
||||
subset_study_path=args.subset_study_json,
|
||||
output_json_path=args.output_json,
|
||||
output_csv_path=args.output_csv,
|
||||
figure_path=args.figure,
|
||||
device_str=args.device,
|
||||
epochs=args.epochs,
|
||||
seeds=seed_list,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Heavy PSO Cross-Split Experiment Runner.
|
||||
|
||||
Protocol Version: HEAVY-PSO-CROSS-SPLIT 1.0.0
|
||||
|
||||
Runs matching baseline and candidate PSO experiments across development or confirmation
|
||||
data splits under sealed official test conditions (official_test_evaluations = 0).
|
||||
|
||||
Phase Specifications:
|
||||
- Development: split_seeds = (20260905, 20260906), swarm_seeds = (101, 102, 103)
|
||||
- Confirmation: split_seeds = (20260907,), swarm_seeds = (111, 112, 113)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
# Ensure test directory and repo root are in Python path
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent if Path(__file__).resolve().parent.name != "PSO" else Path(__file__).resolve().parent
|
||||
TEST_DIR = REPO_ROOT / "test"
|
||||
if str(TEST_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TEST_DIR))
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from benchmark_suite import (
|
||||
get_hardware_provenance,
|
||||
resolve_execution_device,
|
||||
save_json_atomic,
|
||||
)
|
||||
from heavy_pso_autoresearch import (
|
||||
DEFAULT_GEOMETRY_MULTIPLIER,
|
||||
parse_projection_seed_arg,
|
||||
parse_projection_scope_arg,
|
||||
validate_projection_scope_config,
|
||||
get_effective_projection_scope,
|
||||
run_heavy_pso_autoresearch,
|
||||
)
|
||||
from heavy_task_feasibility import (
|
||||
WORKLOADS,
|
||||
run_heavy_task_confirm,
|
||||
)
|
||||
from pso import __version__ as pso_version
|
||||
|
||||
PROTOCOL_VERSION = "HEAVY-PSO-CROSS-SPLIT 1.0.0"
|
||||
|
||||
PHASE_CONFIGS = {
|
||||
"development": {
|
||||
"split_seeds": [20260905, 20260906],
|
||||
"swarm_seeds": [101, 102, 103],
|
||||
},
|
||||
"confirmation": {
|
||||
"split_seeds": [20260907],
|
||||
"swarm_seeds": [111, 112, 113],
|
||||
},
|
||||
}
|
||||
|
||||
WORKLOAD_BASELINE_METHODS = {
|
||||
"mnist_compact": "G8",
|
||||
"mnist_wide": "G5",
|
||||
"fashion_compact": "G8",
|
||||
"fashion_wide": "G5",
|
||||
}
|
||||
|
||||
FROZEN_PROJECTION_SEEDS = {
|
||||
"mnist_compact": 1800044939,
|
||||
"mnist_wide": 592157828,
|
||||
"fashion_compact": 1363313651,
|
||||
"fashion_wide": 189641451,
|
||||
}
|
||||
|
||||
|
||||
def run_heavy_pso_cross_split(
|
||||
phase: str = "development",
|
||||
ratio: float = 0.5,
|
||||
geometry_policy: str = "baseline_aligned",
|
||||
projection_scope: Union[str, Dict[str, str]] = "global",
|
||||
projection_seed_mode: str = "explicit",
|
||||
projection_seed: Optional[Union[int, Dict[str, int]]] = None,
|
||||
geometry_multiplier: float = DEFAULT_GEOMETRY_MULTIPLIER,
|
||||
particles: int = 12,
|
||||
epochs: int = 80,
|
||||
subset_size: int = 10000,
|
||||
device_str: Optional[str] = None,
|
||||
cache_dir: Optional[Path] = None,
|
||||
output_path: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Runs cross-split evaluation for development or confirmation phase.
|
||||
Enforces exact split and swarm seed contracts for each phase.
|
||||
Reruns matching baseline and candidate models per split seed.
|
||||
"""
|
||||
projection_scope = parse_projection_scope_arg(projection_scope)
|
||||
validate_projection_scope_config(projection_scope)
|
||||
if phase not in PHASE_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Invalid phase '{phase}'. Must be one of {list(PHASE_CONFIGS.keys())}"
|
||||
)
|
||||
|
||||
phase_spec = PHASE_CONFIGS[phase]
|
||||
split_seeds = phase_spec["split_seeds"]
|
||||
swarm_seeds = phase_spec["swarm_seeds"]
|
||||
|
||||
if projection_seed_mode == "explicit" and projection_seed is None:
|
||||
projection_seed = dict(FROZEN_PROJECTION_SEEDS)
|
||||
|
||||
start_time = time.time()
|
||||
device = resolve_execution_device(device_str)
|
||||
hardware_info = get_hardware_provenance(device)
|
||||
|
||||
if cache_dir is None:
|
||||
cache_dir = REPO_ROOT / "result" / "cache"
|
||||
|
||||
splits_payload: Dict[str, Any] = {}
|
||||
total_runs = 0
|
||||
total_queries = 0
|
||||
total_samples_evaluated = 0
|
||||
|
||||
for split_seed in split_seeds:
|
||||
# 1. Baseline runs for this split seed
|
||||
selected_baseline_methods = {
|
||||
wl_id: [WORKLOAD_BASELINE_METHODS[wl_id]] for wl_id in WORKLOADS
|
||||
}
|
||||
baseline_res = run_heavy_task_confirm(
|
||||
workloads=WORKLOADS,
|
||||
selected_methods=selected_baseline_methods,
|
||||
particles=particles,
|
||||
epochs=epochs,
|
||||
seeds=swarm_seeds,
|
||||
split_seed=split_seed,
|
||||
device=device,
|
||||
cache_dir=cache_dir,
|
||||
)
|
||||
|
||||
# 2. Candidate runs for this split seed
|
||||
candidate_res = run_heavy_pso_autoresearch(
|
||||
ratios=[ratio],
|
||||
particles=particles,
|
||||
epochs=epochs,
|
||||
subset_size=subset_size,
|
||||
seeds=swarm_seeds,
|
||||
geometry_policy=geometry_policy,
|
||||
device_str=device_str,
|
||||
cache_dir=cache_dir,
|
||||
split_seed=split_seed,
|
||||
projection_scope=projection_scope,
|
||||
projection_seed_mode=projection_seed_mode,
|
||||
projection_seed=projection_seed,
|
||||
geometry_multiplier=geometry_multiplier,
|
||||
)
|
||||
|
||||
# Extract candidate ratio payload
|
||||
candidate_ratio_runs = list(candidate_res["candidate_runs"].values())[0]
|
||||
|
||||
baselines_split: Dict[str, Any] = {}
|
||||
candidates_split: Dict[str, Any] = {}
|
||||
dataset_fingerprints: Dict[str, str] = {}
|
||||
split_fingerprints: Dict[str, str] = {}
|
||||
|
||||
for wl_id in WORKLOADS:
|
||||
b_method = WORKLOAD_BASELINE_METHODS[wl_id]
|
||||
b_entry = baseline_res[wl_id][b_method]
|
||||
baselines_split[wl_id] = b_entry
|
||||
|
||||
c_entry = candidate_ratio_runs[wl_id]
|
||||
candidates_split[wl_id] = c_entry
|
||||
|
||||
dataset_name = WORKLOADS[wl_id].dataset_name
|
||||
dataset_fingerprints[dataset_name] = c_entry["data_fingerprint"]
|
||||
split_fingerprints[dataset_name] = c_entry["split_fingerprint"]
|
||||
|
||||
# Resource accumulation
|
||||
for r in b_entry["per_seed_runs"]:
|
||||
total_runs += 1
|
||||
total_queries += r["total_queries"]
|
||||
total_samples_evaluated += r["total_sample_evaluations"]
|
||||
for r in c_entry["per_seed_runs"]:
|
||||
total_runs += 1
|
||||
total_queries += r["total_queries"]
|
||||
total_samples_evaluated += r["total_sample_evaluations"]
|
||||
|
||||
splits_payload[str(split_seed)] = {
|
||||
"split_seed": split_seed,
|
||||
"data_fingerprints": dataset_fingerprints,
|
||||
"split_fingerprints": split_fingerprints,
|
||||
"baselines": baselines_split,
|
||||
"candidates": candidates_split,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"version": PROTOCOL_VERSION,
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"phase": phase,
|
||||
"split_seeds": list(split_seeds),
|
||||
"swarm_seeds": list(swarm_seeds),
|
||||
"official_test_data_loaded": False,
|
||||
"official_test_evaluations": 0,
|
||||
"candidate_config": {
|
||||
"ratio": ratio,
|
||||
"geometry_policy": geometry_policy,
|
||||
"projection_scope": projection_scope,
|
||||
"projection_seed_mode": projection_seed_mode,
|
||||
"projection_seed": projection_seed,
|
||||
"geometry_multiplier": float(geometry_multiplier),
|
||||
"particles": particles,
|
||||
"epochs": epochs,
|
||||
"subset_size": subset_size,
|
||||
},
|
||||
"workloads": {
|
||||
wl_id: {
|
||||
"workload_id": wl_id,
|
||||
"dataset_name": wl_cfg.dataset_name,
|
||||
"model_name": wl_cfg.model_name,
|
||||
"baseline_method": WORKLOAD_BASELINE_METHODS[wl_id],
|
||||
"projection_scope": get_effective_projection_scope(projection_scope, wl_id),
|
||||
"effective_projection_seed": (
|
||||
projection_seed[wl_id]
|
||||
if isinstance(projection_seed, dict)
|
||||
else projection_seed
|
||||
),
|
||||
}
|
||||
for wl_id, wl_cfg in WORKLOADS.items()
|
||||
},
|
||||
"splits": splits_payload,
|
||||
"resource_totals": {
|
||||
"total_runs": total_runs,
|
||||
"total_queries": total_queries,
|
||||
"total_samples_evaluated": total_samples_evaluated,
|
||||
"official_test_evaluations": 0,
|
||||
"wall_time_sec": round(time.time() - start_time, 4),
|
||||
},
|
||||
"provenance": {
|
||||
"hardware": hardware_info,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"pso_version": pso_version,
|
||||
},
|
||||
}
|
||||
|
||||
if output_path is not None:
|
||||
save_json_atomic(payload, output_path)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Heavy PSO Cross-Split Experiment Runner (Development / Confirmation)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--phase",
|
||||
type=str,
|
||||
default="development",
|
||||
choices=list(PHASE_CONFIGS.keys()),
|
||||
help="Experiment phase ('development' or 'confirmation')",
|
||||
)
|
||||
parser.add_argument("--device", type=str, default=None, help="Device (cpu, mps, cuda)")
|
||||
parser.add_argument("--cache-dir", type=str, default=None, help="Dataset cache directory")
|
||||
parser.add_argument("--output", type=str, default=None, help="Output artifact JSON path")
|
||||
parser.add_argument(
|
||||
"--ratio",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Subspace ratio for candidate PSO (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geometry-policy",
|
||||
type=str,
|
||||
default="baseline_aligned",
|
||||
help="Geometry policy (default: 'baseline_aligned')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--projection-scope",
|
||||
type=parse_projection_scope_arg,
|
||||
default="global",
|
||||
help="Projection scope ('global', 'tensor_local', 'balanced_global', 'two_hash_global', 'largest_tensor_hash', 'largest_tensor_row_hash', 'adjacent_pair', 'adjacent_difference', or workload dict)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--projection-seed-mode",
|
||||
type=str,
|
||||
default="explicit",
|
||||
help="Projection seed mode (default: 'explicit')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--projection-seed",
|
||||
type=parse_projection_seed_arg,
|
||||
default=None,
|
||||
help="Exact projection seed (int or dict) for explicit mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geometry-multiplier",
|
||||
type=float,
|
||||
default=DEFAULT_GEOMETRY_MULTIPLIER,
|
||||
help="Geometry multiplier (default: 1.0)",
|
||||
)
|
||||
parser.add_argument("--particles", type=int, default=12, help="Swarm size (default: 12)")
|
||||
parser.add_argument("--epochs", type=int, default=80, help="PSO epochs (default: 80)")
|
||||
parser.add_argument(
|
||||
"--subset-size",
|
||||
type=int,
|
||||
default=10000,
|
||||
help="Subset size (default: 10000)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
cache_path = Path(args.cache_dir) if args.cache_dir else None
|
||||
out_path = Path(args.output) if args.output else None
|
||||
|
||||
run_heavy_pso_cross_split(
|
||||
phase=args.phase,
|
||||
ratio=args.ratio,
|
||||
geometry_policy=args.geometry_policy,
|
||||
projection_scope=args.projection_scope,
|
||||
projection_seed_mode=args.projection_seed_mode,
|
||||
projection_seed=args.projection_seed,
|
||||
geometry_multiplier=args.geometry_multiplier,
|
||||
particles=args.particles,
|
||||
epochs=args.epochs,
|
||||
subset_size=args.subset_size,
|
||||
device_str=args.device,
|
||||
cache_dir=cache_path,
|
||||
output_path=out_path,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
+100
-62
@@ -1,73 +1,111 @@
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
|
||||
import argparse
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn.model_selection import train_test_split
|
||||
from tensorflow import keras
|
||||
from tensorflow.keras import layers
|
||||
from tensorflow.keras.models import Sequential
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from pso import optimizer
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
from pso import Optimizer
|
||||
from cli import add_pso_args, build_optimizer_kwargs
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(layers.Dense(10, activation="relu", input_shape=(4,)))
|
||||
model.add(layers.Dense(10, activation="relu"))
|
||||
model.add(layers.Dense(3, activation="softmax"))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def load_data():
|
||||
iris = load_iris()
|
||||
x = iris.data
|
||||
y = iris.target
|
||||
|
||||
y = keras.utils.to_categorical(y, 3)
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y, test_size=0.2, shuffle=True, stratify=y
|
||||
def make_model(seed: int = 42):
|
||||
torch.manual_seed(seed)
|
||||
return nn.Sequential(
|
||||
nn.Linear(4, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 3),
|
||||
)
|
||||
|
||||
return x_train, x_test, y_train, y_test
|
||||
|
||||
def load_data(seed: int = 42):
|
||||
iris = load_iris()
|
||||
x = iris.data.astype("float32")
|
||||
y = iris.target.astype("int64")
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y, test_size=0.2, shuffle=True, stratify=y, random_state=seed
|
||||
)
|
||||
scaler = StandardScaler()
|
||||
x_train = scaler.fit_transform(x_train)
|
||||
x_test = scaler.transform(x_test)
|
||||
|
||||
return (
|
||||
torch.tensor(x_train, dtype=torch.float32),
|
||||
torch.tensor(x_test, dtype=torch.float32),
|
||||
torch.tensor(y_train, dtype=torch.int64),
|
||||
torch.tensor(y_test, dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
model = make_model()
|
||||
x_train, x_test, y_train, y_test = load_data()
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PSO Iris Benchmark Script")
|
||||
add_pso_args(
|
||||
parser,
|
||||
defaults={
|
||||
"method": "original",
|
||||
"initialization": "model_noise",
|
||||
"evaluation": "full",
|
||||
"convergence": "particle_reset",
|
||||
"refinement": "adam",
|
||||
"n_particles": 24,
|
||||
"c0": None,
|
||||
"c1": None,
|
||||
"w_min": None,
|
||||
"w_max": None,
|
||||
"negative_swarm": 0.0,
|
||||
"mutation_swarm": 0.1,
|
||||
"particle_min": -3.0,
|
||||
"particle_max": 3.0,
|
||||
"velocity_limit_ratio": 0.1,
|
||||
"boundary_strategy": "reflect",
|
||||
"seed": 42,
|
||||
"epochs": 70,
|
||||
"renewal": "loss",
|
||||
"output_dir": "output/iris",
|
||||
"checkpoint_interval": 25,
|
||||
"refinement_epochs": 10,
|
||||
"refinement_lr": 0.001,
|
||||
},
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
model = make_model(seed=args.seed)
|
||||
x_train, x_test, y_train, y_test = load_data(seed=args.seed)
|
||||
|
||||
fitness_size = args.fitness_size if args.evaluation == "fixed_subset" else None
|
||||
refinement_epochs = args.refinement_epochs if args.refinement == "adam" else 0
|
||||
|
||||
kwargs = build_optimizer_kwargs(
|
||||
args,
|
||||
model=model,
|
||||
loss=nn.CrossEntropyLoss(),
|
||||
task="multiclass",
|
||||
inertia_profile={"c0": 0.5, "c1": 0.3, "w_min": 0.1, "w_max": 0.9},
|
||||
)
|
||||
pso_iris = Optimizer(**kwargs)
|
||||
|
||||
print(f"Optimizer device: {pso_iris.device}")
|
||||
|
||||
best_score = pso_iris.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=fitness_size,
|
||||
renewal=args.renewal,
|
||||
validation_data=(x_test, y_test),
|
||||
output_dir=args.output_dir,
|
||||
checkpoint_interval=25,
|
||||
save_info=True,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
|
||||
print(f"Done! Best score: {best_score}")
|
||||
|
||||
|
||||
pso_iris = optimizer(
|
||||
model=model,
|
||||
loss="categorical_crossentropy",
|
||||
n_particles=100,
|
||||
c0=0.5,
|
||||
c1=0.3,
|
||||
w_min=0.1,
|
||||
w_max=0.9,
|
||||
negative_swarm=0,
|
||||
mutation_swarm=0.1,
|
||||
convergence_reset=True,
|
||||
convergence_reset_patience=10,
|
||||
convergence_reset_monitor="loss",
|
||||
convergence_reset_min_delta=0.001,
|
||||
)
|
||||
|
||||
best_score = pso_iris.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=500,
|
||||
save_info=True,
|
||||
log=2,
|
||||
log_name="iris",
|
||||
renewal="loss",
|
||||
check_point=25,
|
||||
validate_data=(x_test, y_test),
|
||||
)
|
||||
|
||||
gc.collect()
|
||||
print("Done!")
|
||||
sys.exit(0)
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import os
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
|
||||
import tensorflow as tf
|
||||
|
||||
gpus = tf.config.experimental.list_physical_devices("GPU")
|
||||
if gpus:
|
||||
try:
|
||||
# tf.config.experimental.set_visible_devices(gpus[0], "GPU")
|
||||
tf.config.experimental.set_memory_growth(gpus[0], True)
|
||||
except RuntimeError as e:
|
||||
print(e)
|
||||
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn.model_selection import train_test_split
|
||||
from tensorflow import keras
|
||||
from tensorflow.keras import layers
|
||||
from tensorflow.keras.models import Sequential
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(layers.Dense(10, activation="relu", input_shape=(4,)))
|
||||
model.add(layers.Dense(10, activation="relu"))
|
||||
model.add(layers.Dense(3, activation="softmax"))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def load_data():
|
||||
iris = load_iris()
|
||||
x = iris.data
|
||||
y = iris.target
|
||||
|
||||
y = keras.utils.to_categorical(y, 3)
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y, test_size=0.2, shuffle=True, stratify=y
|
||||
)
|
||||
|
||||
return x_train, x_test, y_train, y_test
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
model = make_model()
|
||||
x_train, x_test, y_train, y_test = load_data()
|
||||
print(x_train.shape, y_train.shape)
|
||||
|
||||
loss = ["categorical_crossentropy", "accuracy", "mse"]
|
||||
metrics = ["accuracy"]
|
||||
|
||||
model.compile(optimizer="sgd", loss=loss[0], metrics=metrics[0])
|
||||
model.fit(x_train, y_train, epochs=200, batch_size=32, validation_split=0.2)
|
||||
model.evaluate(x_test, y_test, batch_size=32)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Iris dataset gradient baseline (PyTorch standard backprop optimizer, non-PSO)."""
|
||||
|
||||
import copy
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
|
||||
class IrisModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(
|
||||
nn.Linear(4, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 10),
|
||||
nn.ReLU(),
|
||||
nn.Linear(10, 3),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net(x)
|
||||
|
||||
|
||||
def load_data(seed: int = 42):
|
||||
iris = load_iris()
|
||||
X = iris.data.astype("float32")
|
||||
y = iris.target.astype("int64")
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=0.2, shuffle=True, stratify=y, random_state=seed
|
||||
)
|
||||
return (
|
||||
torch.tensor(x_train, dtype=torch.float32),
|
||||
torch.tensor(x_test, dtype=torch.float32),
|
||||
torch.tensor(y_train, dtype=torch.int64),
|
||||
torch.tensor(y_test, dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
def get_device() -> torch.device:
|
||||
if (
|
||||
hasattr(torch.backends, "mps")
|
||||
and torch.backends.mps.is_built()
|
||||
and torch.backends.mps.is_available()
|
||||
):
|
||||
return torch.device("mps")
|
||||
elif torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def main():
|
||||
torch.manual_seed(42)
|
||||
np.random.seed(42)
|
||||
|
||||
device = get_device()
|
||||
print(f"Selected device: {device}")
|
||||
|
||||
x_train, x_test, y_train, y_test = load_data(seed=42)
|
||||
train_loader = DataLoader(
|
||||
TensorDataset(x_train, y_train), batch_size=32, shuffle=True
|
||||
)
|
||||
val_loader = DataLoader(
|
||||
TensorDataset(x_test, y_test), batch_size=32, shuffle=False
|
||||
)
|
||||
|
||||
model = IrisModel().to(device)
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
best_val_loss = float("inf")
|
||||
best_state = None
|
||||
|
||||
for epoch in range(200):
|
||||
model.train()
|
||||
for bx, by in train_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
optimizer.zero_grad()
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
model.eval()
|
||||
val_loss = 0.0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in val_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
val_loss += loss.item() * bx.size(0)
|
||||
total += bx.size(0)
|
||||
|
||||
val_loss /= total
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
best_state = copy.deepcopy(model.state_dict())
|
||||
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
test_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in val_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
test_loss += loss.item() * bx.size(0)
|
||||
preds = out.argmax(dim=1)
|
||||
correct += (preds == by).sum().item()
|
||||
total += bx.size(0)
|
||||
|
||||
test_loss /= total
|
||||
test_acc = correct / total
|
||||
print(f"Final test loss: {test_loss:.4f}, accuracy: {test_acc:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+94
-71
@@ -1,88 +1,111 @@
|
||||
# %%
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from pso import optimizer
|
||||
|
||||
import tensorflow as tf
|
||||
from keras.datasets import mnist
|
||||
from keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPooling2D
|
||||
from keras.models import Sequential
|
||||
from pso import Optimizer
|
||||
from cli import add_pso_args, build_optimizer_kwargs
|
||||
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
def get_data(seed: int = 42):
|
||||
from sklearn.decomposition import PCA
|
||||
from torchvision.datasets import MNIST
|
||||
|
||||
train_dataset = MNIST(root="./data", train=True, download=True)
|
||||
test_dataset = MNIST(root="./data", train=False, download=True)
|
||||
|
||||
def get_data():
|
||||
(x_train, y_train), (x_test, y_test) = mnist.load_data()
|
||||
x_train_raw = (train_dataset.data[:3000].float() / 255.0).reshape(3000, -1).numpy()
|
||||
y_train = train_dataset.targets[:3000].long()
|
||||
|
||||
x_train, x_test = x_train / 255.0, x_test / 255.0
|
||||
x_train = x_train.reshape((60000, 28, 28, 1))
|
||||
x_test = x_test.reshape((10000, 28, 28, 1))
|
||||
x_test_raw = (test_dataset.data[:1000].float() / 255.0).reshape(1000, -1).numpy()
|
||||
y_test = test_dataset.targets[:1000].long()
|
||||
|
||||
y_train, y_test = tf.one_hot(y_train, 10), tf.one_hot(y_test, 10)
|
||||
pca = PCA(n_components=32, whiten=True, random_state=seed)
|
||||
x_train_pca = pca.fit_transform(x_train_raw)
|
||||
x_test_pca = pca.transform(x_test_raw)
|
||||
|
||||
x_train, x_test = tf.convert_to_tensor(x_train), tf.convert_to_tensor(x_test)
|
||||
y_train, y_test = tf.convert_to_tensor(y_train), tf.convert_to_tensor(y_test)
|
||||
x_train = torch.tensor(x_train_pca, dtype=torch.float32)
|
||||
x_test = torch.tensor(x_test_pca, dtype=torch.float32)
|
||||
|
||||
print(f"x_train : {x_train[0].shape} | y_train : {y_train[0].shape}")
|
||||
print(f"x_test : {x_test[0].shape} | y_test : {y_test[0].shape}")
|
||||
print(f"x_train : {x_train.shape} | y_train : {y_train.shape}")
|
||||
print(f"x_test : {x_test.shape} | y_test : {y_test.shape}")
|
||||
|
||||
return x_train, y_train, x_test, y_test
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(
|
||||
Conv2D(32, kernel_size=(5, 5), activation="relu", input_shape=(28, 28, 1))
|
||||
def make_model(seed: int = 42):
|
||||
torch.manual_seed(seed)
|
||||
return nn.Linear(32, 10)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PSO MNIST Benchmark Script")
|
||||
add_pso_args(
|
||||
parser,
|
||||
defaults={
|
||||
"method": "inertia",
|
||||
"initialization": "model_noise",
|
||||
"evaluation": "fixed_subset",
|
||||
"convergence": "none",
|
||||
"refinement": "adam",
|
||||
"n_particles": 30,
|
||||
"c0": None,
|
||||
"c1": None,
|
||||
"w_min": None,
|
||||
"w_max": None,
|
||||
"negative_swarm": 0.0,
|
||||
"mutation_swarm": 0.02,
|
||||
"particle_min": -3.0,
|
||||
"particle_max": 3.0,
|
||||
"velocity_limit_ratio": 0.025,
|
||||
"boundary_strategy": "reflect",
|
||||
"initial_position_noise": 0.05,
|
||||
"seed": 42,
|
||||
"epochs": 80,
|
||||
"batch_size": 1000,
|
||||
"fitness_size": 2000,
|
||||
"renewal": "loss",
|
||||
"output_dir": "output/mnist",
|
||||
"checkpoint_interval": 25,
|
||||
"refinement_epochs": 100,
|
||||
"refinement_lr": 0.01,
|
||||
},
|
||||
)
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Conv2D(64, kernel_size=(3, 3), activation="relu"))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Flatten())
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Dense(256, activation="relu"))
|
||||
model.add(Dense(128, activation="relu"))
|
||||
model.add(Dense(10, activation="softmax"))
|
||||
args = parser.parse_args()
|
||||
|
||||
return model
|
||||
model = make_model(seed=args.seed)
|
||||
x_train, y_train, x_test, y_test = get_data(seed=args.seed)
|
||||
|
||||
fitness_size = args.fitness_size if args.evaluation == "fixed_subset" else None
|
||||
refinement_epochs = args.refinement_epochs if args.refinement == "adam" else 0
|
||||
|
||||
kwargs = build_optimizer_kwargs(
|
||||
args,
|
||||
model=model,
|
||||
loss=nn.CrossEntropyLoss(),
|
||||
task="multiclass",
|
||||
inertia_profile={"c0": 1.49618, "c1": 1.49618, "w_min": 0.7298, "w_max": 0.7298},
|
||||
)
|
||||
pso_mnist = Optimizer(**kwargs)
|
||||
|
||||
print(f"Optimizer device: {pso_mnist.device}")
|
||||
|
||||
best_score = pso_mnist.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=fitness_size,
|
||||
renewal=args.renewal,
|
||||
validation_data=(x_test, y_test),
|
||||
output_dir=args.output_dir,
|
||||
checkpoint_interval=25,
|
||||
save_info=True,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
|
||||
print(f"Done! Best score: {best_score}")
|
||||
|
||||
|
||||
# %%
|
||||
model = make_model()
|
||||
x_train, y_train, x_test, y_test = get_data()
|
||||
|
||||
|
||||
pso_mnist = optimizer(
|
||||
model,
|
||||
loss="categorical_crossentropy",
|
||||
n_particles=200,
|
||||
c0=0.7,
|
||||
c1=0.4,
|
||||
w_min=0.1,
|
||||
w_max=0.9,
|
||||
negative_swarm=0.0,
|
||||
mutation_swarm=0.05,
|
||||
convergence_reset=True,
|
||||
convergence_reset_patience=10,
|
||||
convergence_reset_monitor="loss",
|
||||
convergence_reset_min_delta=0.005,
|
||||
)
|
||||
|
||||
best_score = pso_mnist.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=1000,
|
||||
save_info=True,
|
||||
log=2,
|
||||
log_name="mnist",
|
||||
renewal="loss",
|
||||
check_point=25,
|
||||
batch_size=5000,
|
||||
validate_data=(x_test, y_test),
|
||||
)
|
||||
|
||||
print("Done!")
|
||||
|
||||
sys.exit(0)
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
from keras.models import Sequential
|
||||
from keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPooling2D
|
||||
from keras.datasets import mnist
|
||||
from keras.utils import to_categorical
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
# from tensorflow.data.Dataset import from_tensor_slices
|
||||
import tensorflow as tf
|
||||
import os
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
|
||||
|
||||
gpus = tf.config.experimental.list_physical_devices("GPU")
|
||||
if gpus:
|
||||
try:
|
||||
tf.config.experimental.set_memory_growth(gpus[0], True)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
finally:
|
||||
del gpus
|
||||
|
||||
|
||||
def get_data():
|
||||
(x_train, y_train), (x_test, y_test) = mnist.load_data()
|
||||
|
||||
x_train, x_test = x_train / 255.0, x_test / 255.0
|
||||
x_train = x_train.reshape((60000, 28, 28, 1))
|
||||
x_test = x_test.reshape((10000, 28, 28, 1))
|
||||
|
||||
print(f"x_train : {x_train[0].shape} | y_train : {y_train[0].shape}")
|
||||
print(f"x_test : {x_test[0].shape} | y_test : {y_test[0].shape}")
|
||||
|
||||
return x_train, y_train, x_test, y_test
|
||||
|
||||
class _batch_generator_:
|
||||
def __init__(self, x, y, batch_size: int = None):
|
||||
self.index = 0
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.setBatchSize(batch_size)
|
||||
|
||||
def next(self):
|
||||
self.index += 1
|
||||
if self.index >= self.max_index:
|
||||
self.index = 0
|
||||
self.__getBatchSlice(self.batch_size)
|
||||
return self.dataset[self.index][0], self.dataset[self.index][1]
|
||||
|
||||
def getMaxIndex(self):
|
||||
return self.max_index
|
||||
|
||||
def getIndex(self):
|
||||
return self.index
|
||||
|
||||
def setIndex(self, index):
|
||||
self.index = index
|
||||
|
||||
def getBatchSize(self):
|
||||
return self.batch_size
|
||||
|
||||
def setBatchSize(self, batch_size: int = None):
|
||||
if batch_size is None:
|
||||
batch_size = len(self.x) // 10
|
||||
elif batch_size > len(self.x):
|
||||
batch_size = len(self.x)
|
||||
self.batch_size = batch_size
|
||||
print(f"batch size : {self.batch_size}")
|
||||
self.dataset = self.__getBatchSlice(self.batch_size)
|
||||
self.max_index = len(self.dataset)
|
||||
|
||||
def __getBatchSlice(self, batch_size):
|
||||
return list(
|
||||
tf.data.Dataset.from_tensor_slices((self.x, self.y))
|
||||
.shuffle(len(self.x))
|
||||
.batch(batch_size)
|
||||
)
|
||||
|
||||
def getDataset(self):
|
||||
return self.dataset
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(
|
||||
Conv2D(64, kernel_size=(5, 5), activation="relu", input_shape=(28, 28, 1))
|
||||
)
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Conv2D(128, kernel_size=(3, 3), activation="relu"))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Flatten())
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Dense(2048, activation="relu"))
|
||||
model.add(Dropout(0.8))
|
||||
model.add(Dense(1024, activation="relu"))
|
||||
model.add(Dropout(0.8))
|
||||
model.add(Dense(10, activation="softmax"))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
model = make_model()
|
||||
x_train, y_train, x_test, y_test = get_data()
|
||||
y_train = tf.one_hot(y_train, 10)
|
||||
y_test = tf.one_hot(y_test, 10)
|
||||
|
||||
batch = 64
|
||||
dataset = _batch_generator_(x_train, y_train, batch)
|
||||
|
||||
model.compile(
|
||||
optimizer="adam",
|
||||
loss="categorical_crossentropy",
|
||||
metrics=["accuracy", "mse"],
|
||||
)
|
||||
|
||||
count = 0
|
||||
print(f"batch size : {batch}")
|
||||
print("iter " + str(dataset.getMaxIndex()))
|
||||
print("Training model...")
|
||||
# while count < dataset.getMaxIndex():
|
||||
# x_batch, y_batch = dataset.next()
|
||||
# count += 1
|
||||
# print(f"iter {count}/{dataset.getMaxIndex()}")
|
||||
model.fit(x_train, y_train, epochs=1000, batch_size=batch, verbose=1)
|
||||
|
||||
print(count)
|
||||
|
||||
print("Evaluating model...")
|
||||
model.evaluate(x_test, y_test, verbose=1)
|
||||
|
||||
weights = model.get_weights()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""MNIST dataset gradient baseline (PyTorch standard backprop optimizer, non-PSO)."""
|
||||
|
||||
import copy
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
|
||||
class MNISTModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.conv1 = nn.Conv2d(1, 64, kernel_size=5)
|
||||
self.relu1 = nn.ReLU()
|
||||
self.pool1 = nn.MaxPool2d(2, 2)
|
||||
self.drop1 = nn.Dropout(0.5)
|
||||
|
||||
self.conv2 = nn.Conv2d(64, 128, kernel_size=3)
|
||||
self.relu2 = nn.ReLU()
|
||||
self.pool2 = nn.MaxPool2d(2, 2)
|
||||
|
||||
self.drop2 = nn.Dropout(0.5)
|
||||
self.fc1 = nn.Linear(128 * 5 * 5, 2048)
|
||||
self.relu3 = nn.ReLU()
|
||||
self.drop3 = nn.Dropout(0.8)
|
||||
|
||||
self.fc2 = nn.Linear(2048, 1024)
|
||||
self.relu4 = nn.ReLU()
|
||||
self.drop4 = nn.Dropout(0.8)
|
||||
|
||||
self.fc3 = nn.Linear(1024, 10)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.drop1(self.pool1(self.relu1(self.conv1(x))))
|
||||
x = self.pool2(self.relu2(self.conv2(x)))
|
||||
x = torch.flatten(x, 1)
|
||||
x = self.drop3(self.relu3(self.fc1(self.drop2(x))))
|
||||
x = self.drop4(self.relu4(self.fc2(x)))
|
||||
x = self.fc3(x)
|
||||
return x
|
||||
|
||||
|
||||
def get_data(download: bool = True):
|
||||
from torchvision import datasets, transforms
|
||||
|
||||
transform = transforms.ToTensor()
|
||||
train_dataset = datasets.MNIST(
|
||||
root="./data", train=True, transform=transform, download=download
|
||||
)
|
||||
test_dataset = datasets.MNIST(
|
||||
root="./data", train=False, transform=transform, download=download
|
||||
)
|
||||
return train_dataset, test_dataset
|
||||
|
||||
|
||||
def get_device() -> torch.device:
|
||||
if (
|
||||
hasattr(torch.backends, "mps")
|
||||
and torch.backends.mps.is_built()
|
||||
and torch.backends.mps.is_available()
|
||||
):
|
||||
return torch.device("mps")
|
||||
elif torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def main():
|
||||
torch.manual_seed(42)
|
||||
np.random.seed(42)
|
||||
|
||||
device = get_device()
|
||||
print(f"Selected device: {device}")
|
||||
|
||||
train_dataset, test_dataset = get_data(download=True)
|
||||
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
|
||||
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
|
||||
|
||||
model = MNISTModel().to(device)
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
best_val_loss = float("inf")
|
||||
best_state = None
|
||||
|
||||
for epoch in range(10):
|
||||
model.train()
|
||||
for bx, by in train_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
optimizer.zero_grad()
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
model.eval()
|
||||
val_loss = 0.0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in test_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
val_loss += loss.item() * bx.size(0)
|
||||
total += bx.size(0)
|
||||
|
||||
val_loss /= total
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
best_state = copy.deepcopy(model.state_dict())
|
||||
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
test_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for bx, by in test_loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
loss = criterion(out, by)
|
||||
test_loss += loss.item() * bx.size(0)
|
||||
preds = out.argmax(dim=1)
|
||||
correct += (preds == by).sum().item()
|
||||
total += bx.size(0)
|
||||
|
||||
test_loss /= total
|
||||
test_acc = correct / total
|
||||
print(f"Final test loss: {test_loss:.4f}, accuracy: {test_acc:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Emit live, read-only TensorBoard progress for a convergence-study run."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
|
||||
BASE_SEEDS = (501, 502, 503)
|
||||
SWARM_SEEDS = (601, 602, 603)
|
||||
CIFAR_WORKLOADS = ("cifar10_resnet18", "cifar10_resnet50")
|
||||
YOLO_WORKLOAD = "voc_yolo11n"
|
||||
|
||||
|
||||
def _cifar_seed(root: Path, seed: int) -> dict[str, Any]:
|
||||
baseline = root / f"baseline-{seed}.pt"
|
||||
searches = sum(
|
||||
(root / f"feature-{method}-{seed}-{swarm}.json").is_file()
|
||||
for method in ("pso", "random")
|
||||
for swarm in SWARM_SEEDS
|
||||
)
|
||||
controls = sum(
|
||||
(root / f"{method}-{seed}.pt").is_file()
|
||||
for method in ("feature-adam", "head-adam")
|
||||
)
|
||||
complete = baseline.is_file() and searches == 6 and controls == 2
|
||||
if complete:
|
||||
stage = "complete"
|
||||
elif not baseline.is_file():
|
||||
stage = "baseline_training"
|
||||
elif searches < 6:
|
||||
stage = f"pso_random_search_{searches}_of_6"
|
||||
else:
|
||||
stage = f"adam_controls_{controls}_of_2"
|
||||
return {
|
||||
"seed": seed,
|
||||
"stage": stage,
|
||||
"complete": complete,
|
||||
"searches": searches,
|
||||
"controls": controls,
|
||||
}
|
||||
|
||||
|
||||
def _read_yolo_metrics(path: Path) -> list[dict[str, float]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
try:
|
||||
with path.open(newline="", encoding="utf-8") as stream:
|
||||
rows = []
|
||||
for source in csv.DictReader(stream):
|
||||
rows.append(
|
||||
{
|
||||
key.strip(): float(value)
|
||||
for key, value in source.items()
|
||||
if key is not None
|
||||
and value is not None
|
||||
and value.strip()
|
||||
}
|
||||
)
|
||||
return rows
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _yolo_seed(
|
||||
root: Path,
|
||||
run_root: Path,
|
||||
seed: int,
|
||||
) -> dict[str, Any]:
|
||||
baseline = root / "baselines" / str(seed) / "ema_fp32.pt"
|
||||
training_root = (
|
||||
run_root / "ultralytics" / f"base-{seed}-100e"
|
||||
)
|
||||
metrics = _read_yolo_metrics(training_root / "results.csv")
|
||||
baseline_complete = (
|
||||
baseline.is_file()
|
||||
and len(metrics) >= 100
|
||||
and (training_root / "weights" / "last.pt").is_file()
|
||||
)
|
||||
arm_root = root / "arms" / str(seed)
|
||||
searches = sum(
|
||||
(arm_root / f"feature_{method}-{swarm}.pt").is_file()
|
||||
for method in ("pso", "random")
|
||||
for swarm in SWARM_SEEDS
|
||||
)
|
||||
controls = sum(
|
||||
(arm_root / f"{method}.pt").is_file()
|
||||
for method in ("feature_adam", "head_adam")
|
||||
)
|
||||
complete = (arm_root / "record.json").is_file()
|
||||
if complete:
|
||||
stage = "complete"
|
||||
elif not baseline_complete:
|
||||
stage = f"baseline_training_epoch_{len(metrics)}_of_100"
|
||||
elif searches < 6:
|
||||
stage = f"pso_random_search_{searches}_of_6"
|
||||
elif controls < 2:
|
||||
stage = f"adam_controls_{controls}_of_2"
|
||||
else:
|
||||
stage = "selection"
|
||||
return {
|
||||
"seed": seed,
|
||||
"stage": stage,
|
||||
"complete": complete,
|
||||
"searches": searches,
|
||||
"controls": controls,
|
||||
"training_metrics": metrics,
|
||||
}
|
||||
|
||||
|
||||
def snapshot(run_root: Path) -> dict[str, Any]:
|
||||
workloads = run_root / "workloads"
|
||||
status: dict[str, Any] = {}
|
||||
completed = 0
|
||||
for workload in CIFAR_WORKLOADS:
|
||||
seeds = [
|
||||
_cifar_seed(workloads / workload, seed)
|
||||
for seed in BASE_SEEDS
|
||||
]
|
||||
completed += sum(item["complete"] for item in seeds)
|
||||
status[workload] = seeds
|
||||
yolo = [
|
||||
_yolo_seed(
|
||||
workloads / YOLO_WORKLOAD,
|
||||
run_root,
|
||||
seed,
|
||||
)
|
||||
for seed in BASE_SEEDS
|
||||
]
|
||||
completed += sum(item["complete"] for item in yolo)
|
||||
status[YOLO_WORKLOAD] = yolo
|
||||
state_path = run_root / "state.json"
|
||||
state = "missing"
|
||||
if state_path.is_file():
|
||||
try:
|
||||
state = str(
|
||||
json.loads(
|
||||
state_path.read_text(encoding="utf-8")
|
||||
).get("state", "unknown")
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
state = "unreadable"
|
||||
active = next(
|
||||
(
|
||||
f"{workload}/seed-{item['seed']}/{item['stage']}"
|
||||
for workload, items in status.items()
|
||||
for item in items
|
||||
if not item["complete"]
|
||||
),
|
||||
"",
|
||||
)
|
||||
if not active:
|
||||
for workload in CIFAR_WORKLOADS:
|
||||
selected = sum(
|
||||
(
|
||||
workloads
|
||||
/ workload
|
||||
/ f"selected-feature_{method}-{seed}.pt"
|
||||
).is_file()
|
||||
for method in ("pso", "random")
|
||||
for seed in BASE_SEEDS
|
||||
)
|
||||
if selected < 6:
|
||||
active = (
|
||||
f"{workload}/development_selection_"
|
||||
f"{selected}_of_6"
|
||||
)
|
||||
break
|
||||
if not active:
|
||||
active = "development_complete"
|
||||
return {
|
||||
"state": state,
|
||||
"active": active,
|
||||
"completed_base_seeds": int(completed),
|
||||
"total_base_seeds": 9,
|
||||
"workloads": status,
|
||||
"observed_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def emit(
|
||||
writer: SummaryWriter,
|
||||
value: dict[str, Any],
|
||||
step: int,
|
||||
) -> None:
|
||||
writer.add_scalar(
|
||||
"progress/completed_base_seeds",
|
||||
value["completed_base_seeds"],
|
||||
step,
|
||||
)
|
||||
writer.add_scalar(
|
||||
"progress/completion_fraction",
|
||||
value["completed_base_seeds"] / value["total_base_seeds"],
|
||||
step,
|
||||
)
|
||||
for workload, seeds in value["workloads"].items():
|
||||
writer.add_scalar(
|
||||
f"progress/{workload}/completed_base_seeds",
|
||||
sum(item["complete"] for item in seeds),
|
||||
step,
|
||||
)
|
||||
for item in value["workloads"][YOLO_WORKLOAD]:
|
||||
seed = item["seed"]
|
||||
for row in item["training_metrics"]:
|
||||
epoch = int(row["epoch"])
|
||||
for metric, metric_value in row.items():
|
||||
if metric in {"epoch", "time"}:
|
||||
continue
|
||||
writer.add_scalar(
|
||||
f"training/{YOLO_WORKLOAD}/seed_{seed}/{metric}",
|
||||
metric_value,
|
||||
epoch,
|
||||
)
|
||||
writer.add_text(
|
||||
"progress/current",
|
||||
f"`{value['active']}`",
|
||||
step,
|
||||
)
|
||||
writer.add_text(
|
||||
"progress/snapshot",
|
||||
f"```json\n{json.dumps(value, indent=2)}\n```",
|
||||
step,
|
||||
)
|
||||
writer.flush()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--run-root", required=True, type=Path)
|
||||
parser.add_argument("--interval", type=float, default=10.0)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.interval <= 0:
|
||||
raise SystemExit("--interval must be positive")
|
||||
log_dir = args.run_root / "tensorboard"
|
||||
writer = SummaryWriter(log_dir=str(log_dir))
|
||||
print(f"monitoring {args.run_root} -> {log_dir}", flush=True)
|
||||
step = 0
|
||||
try:
|
||||
while True:
|
||||
value = snapshot(args.run_root)
|
||||
emit(writer, value, step)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"active": value["active"],
|
||||
"completed_base_seeds": value[
|
||||
"completed_base_seeds"
|
||||
],
|
||||
"state": value["state"],
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
step += 1
|
||||
if args.once:
|
||||
break
|
||||
time.sleep(args.interval)
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+1751
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
Heavy PSO Cross-Split Experiment Results Publisher.
|
||||
|
||||
Protocol Version: HEAVY-PSO-CROSS-SPLIT-PUBLISH 1.0.0
|
||||
|
||||
Reads raw candidate and evaluation artifacts from a completed cross-split mission run,
|
||||
validates integrity, accounting, test seals, and candidate/evaluation alignment across
|
||||
all development variants, and exports deterministic public benchmark artifacts:
|
||||
1. benchmark_results/pso_v7_heavy_cross_split.json
|
||||
2. benchmark_results/pso_v7_heavy_cross_split.csv
|
||||
3. history_plt/pso_v7_heavy_cross_split.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Union
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
# Ensure test directory and repo root are in sys.path
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent if Path(__file__).resolve().parent.name != "PSO" else Path(__file__).resolve().parent
|
||||
TEST_DIR = REPO_ROOT / "test"
|
||||
if str(TEST_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TEST_DIR))
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from benchmark_suite import save_json_atomic
|
||||
from pso import __version__ as pso_version
|
||||
|
||||
PUBLISH_PROTOCOL_VERSION = "HEAVY-PSO-CROSS-SPLIT-PUBLISH 1.0.0"
|
||||
DEFAULT_SOURCE_DIR = Path(".omc/autoresearch/heavy-pso-cross-split-robustness/runs/20260903T162504Z")
|
||||
DEFAULT_JSON_OUTPUT = Path("benchmark_results/pso_v7_heavy_cross_split.json")
|
||||
DEFAULT_CSV_OUTPUT = Path("benchmark_results/pso_v7_heavy_cross_split.csv")
|
||||
DEFAULT_PLOT_OUTPUT = Path("history_plt/pso_v7_heavy_cross_split.png")
|
||||
|
||||
WORKLOADS = ["mnist_compact", "mnist_wide", "fashion_compact", "fashion_wide"]
|
||||
BASELINE_METHODS = {
|
||||
"mnist_compact": "G8",
|
||||
"mnist_wide": "G5",
|
||||
"fashion_compact": "G8",
|
||||
"fashion_wide": "G5",
|
||||
}
|
||||
EXPECTED_DEV_SPLITS = [20260905, 20260906]
|
||||
EXPECTED_DEV_SWARM_SEEDS = [101, 102, 103]
|
||||
EXPECTED_VARIANTS_COUNT = 9
|
||||
EXPECTED_VARIANT_IDS = (
|
||||
"iteration-0001-development",
|
||||
"iteration-0002-development",
|
||||
"iteration-0003-replica1-development",
|
||||
"iteration-0003-replica2-development",
|
||||
"iteration-0004-development",
|
||||
"iteration-0005-development",
|
||||
"iteration-0006-development",
|
||||
"iteration-0007-development",
|
||||
"iteration-0008-development",
|
||||
)
|
||||
EXPECTED_CELLS_PER_VARIANT = 8
|
||||
EXPECTED_RUNS_PER_VARIANT = 48
|
||||
EXPECTED_QUERIES_PER_VARIANT = 46080
|
||||
EXPECTED_SAMPLES_PER_VARIANT = 460800000
|
||||
|
||||
EXPECTED_TOTAL_RUNS = EXPECTED_VARIANTS_COUNT * EXPECTED_RUNS_PER_VARIANT # 432
|
||||
EXPECTED_TOTAL_QUERIES = EXPECTED_VARIANTS_COUNT * EXPECTED_QUERIES_PER_VARIANT # 414720
|
||||
EXPECTED_TOTAL_SAMPLES = EXPECTED_VARIANTS_COUNT * EXPECTED_SAMPLES_PER_VARIANT # 4147200000
|
||||
|
||||
|
||||
def discover_and_load_variants(
|
||||
source_dir: Path,
|
||||
) -> List[Tuple[Path, Path, Dict[str, Any], Dict[str, Any]]]:
|
||||
"""
|
||||
Discovers candidate and evaluation JSON file pairs in source_dir.
|
||||
Supports both subdirectories (candidates/ & evaluations/) and direct directory structure.
|
||||
"""
|
||||
if not source_dir.exists():
|
||||
raise FileNotFoundError(f"Source directory does not exist: {source_dir}")
|
||||
|
||||
cand_dir = source_dir / "candidates"
|
||||
eval_dir = source_dir / "evaluations"
|
||||
|
||||
if cand_dir.is_dir() and eval_dir.is_dir():
|
||||
candidate_files = sorted(cand_dir.glob("*.json"))
|
||||
else:
|
||||
candidate_files = sorted(source_dir.glob("*candidate*.json"))
|
||||
if not candidate_files:
|
||||
candidate_files = sorted(source_dir.glob("*.json"))
|
||||
|
||||
if not candidate_files:
|
||||
raise ValueError(f"No candidate JSON files found in {source_dir}")
|
||||
|
||||
observed_ids = tuple(path.stem for path in candidate_files)
|
||||
if observed_ids != EXPECTED_VARIANT_IDS:
|
||||
raise ValueError(
|
||||
f"Expected exact development variants {list(EXPECTED_VARIANT_IDS)}, "
|
||||
f"got {list(observed_ids)}"
|
||||
)
|
||||
|
||||
pairs = []
|
||||
for cf in candidate_files:
|
||||
if cand_dir.is_dir() and eval_dir.is_dir():
|
||||
ef = eval_dir / cf.name
|
||||
else:
|
||||
ef_name = cf.name.replace("candidate", "evaluation")
|
||||
ef = source_dir / ef_name
|
||||
if not ef.exists():
|
||||
ef = cf
|
||||
|
||||
if not ef.exists():
|
||||
raise FileNotFoundError(f"Missing corresponding evaluation file for candidate {cf.name}: {ef}")
|
||||
|
||||
with cf.open("r", encoding="utf-8") as f:
|
||||
cdata = json.load(f)
|
||||
with ef.open("r", encoding="utf-8") as f:
|
||||
edata = json.load(f)
|
||||
|
||||
pairs.append((cf, ef, cdata, edata))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def validate_variant_pair(
|
||||
cf_path: Path,
|
||||
ef_path: Path,
|
||||
cdata: Dict[str, Any],
|
||||
edata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validates each source candidate/evaluation artifact pair for expected IDs,
|
||||
phase, test seals, no development pass, cell count 8, and resource accounting.
|
||||
"""
|
||||
variant_id = cf_path.stem
|
||||
|
||||
# Phase check
|
||||
c_phase = cdata.get("phase")
|
||||
e_phase = edata.get("phase")
|
||||
if c_phase != "development":
|
||||
raise ValueError(f"[{variant_id}] Candidate phase must be 'development', got: {c_phase}")
|
||||
if e_phase not in (None, "development"):
|
||||
raise ValueError(f"[{variant_id}] Evaluation phase must be 'development', got: {e_phase}")
|
||||
|
||||
# Official test sealed check
|
||||
if cdata.get("official_test_data_loaded") is not False:
|
||||
raise ValueError(f"[{variant_id}] candidate.official_test_data_loaded must be False")
|
||||
if cdata.get("official_test_evaluations") != 0:
|
||||
raise ValueError(f"[{variant_id}] candidate.official_test_evaluations must be 0")
|
||||
|
||||
gates = edata.get("gates", {})
|
||||
if gates and "official_test_sealed" in gates:
|
||||
if not gates["official_test_sealed"].get("pass", False):
|
||||
raise ValueError(f"[{variant_id}] evaluation gate 'official_test_sealed' must pass")
|
||||
|
||||
# No development pass check
|
||||
if edata.get("development_pass") is not False:
|
||||
raise ValueError(f"[{variant_id}] development_pass must be False for all variants")
|
||||
if edata.get("pass") is not False:
|
||||
raise ValueError(f"[{variant_id}] pass must be False for all variants")
|
||||
if edata.get("eligible_for_confirmation") is not False:
|
||||
raise ValueError(f"[{variant_id}] eligible_for_confirmation must be False for all variants")
|
||||
|
||||
# Matching cell count 8
|
||||
summary_metrics = edata.get("summary_metrics", {})
|
||||
dev_cells = summary_metrics.get("development_cells")
|
||||
cell_metrics = edata.get("cell_metrics", [])
|
||||
if dev_cells != EXPECTED_CELLS_PER_VARIANT or len(cell_metrics) != EXPECTED_CELLS_PER_VARIANT:
|
||||
raise ValueError(
|
||||
f"[{variant_id}] Expected {EXPECTED_CELLS_PER_VARIANT} development cells, got "
|
||||
f"summary_metrics.development_cells={dev_cells}, len(cell_metrics)={len(cell_metrics)}"
|
||||
)
|
||||
|
||||
# Resource accounting check across per-seed runs
|
||||
splits = cdata.get("splits", {})
|
||||
c_runs = 0
|
||||
c_queries = 0
|
||||
c_samples = 0
|
||||
c_test_evals = 0
|
||||
|
||||
for split_key, split_data in splits.items():
|
||||
for role in ("baselines", "candidates"):
|
||||
for wl_key, wl_data in split_data.get(role, {}).items():
|
||||
for run in wl_data.get("per_seed_runs", []):
|
||||
c_runs += 1
|
||||
c_queries += run.get("total_queries", 0)
|
||||
c_samples += run.get("total_sample_evaluations", 0)
|
||||
c_test_evals += run.get("official_test_evaluations", 0)
|
||||
|
||||
if c_runs != EXPECTED_RUNS_PER_VARIANT:
|
||||
raise ValueError(f"[{variant_id}] Expected {EXPECTED_RUNS_PER_VARIANT} runs, got {c_runs}")
|
||||
if c_queries != EXPECTED_QUERIES_PER_VARIANT:
|
||||
raise ValueError(f"[{variant_id}] Expected {EXPECTED_QUERIES_PER_VARIANT} total queries, got {c_queries}")
|
||||
if c_samples != EXPECTED_SAMPLES_PER_VARIANT:
|
||||
raise ValueError(f"[{variant_id}] Expected {EXPECTED_SAMPLES_PER_VARIANT} total sample evaluations, got {c_samples}")
|
||||
if c_test_evals != 0:
|
||||
raise ValueError(f"[{variant_id}] Official test evaluations must be 0, got {c_test_evals}")
|
||||
resource_totals = cdata.get("resource_totals")
|
||||
if not isinstance(resource_totals, dict):
|
||||
raise ValueError(f"[{variant_id}] candidate.resource_totals must be present")
|
||||
expected_resource_totals = {
|
||||
"total_runs": c_runs,
|
||||
"total_queries": c_queries,
|
||||
"total_samples_evaluated": c_samples,
|
||||
"official_test_evaluations": c_test_evals,
|
||||
}
|
||||
for field, expected in expected_resource_totals.items():
|
||||
if resource_totals.get(field) != expected:
|
||||
raise ValueError(
|
||||
f"[{variant_id}] candidate.resource_totals.{field} must be {expected}, "
|
||||
f"got {resource_totals.get(field)!r}"
|
||||
)
|
||||
wall_time = resource_totals.get("wall_time_sec")
|
||||
if (
|
||||
not isinstance(wall_time, (int, float))
|
||||
or isinstance(wall_time, bool)
|
||||
or not math.isfinite(float(wall_time))
|
||||
or wall_time < 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"[{variant_id}] candidate.resource_totals.wall_time_sec must be finite and non-negative"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"variant_id": variant_id,
|
||||
"candidate_file": cf_path.name,
|
||||
"evaluation_file": ef_path.name,
|
||||
"candidate_path": _repository_relative_path(cf_path),
|
||||
"evaluation_path": _repository_relative_path(ef_path),
|
||||
"phase": "development",
|
||||
"candidate_config": cdata.get("candidate_config", {}),
|
||||
"score": float(edata.get("score", 0.0)),
|
||||
"pass": False,
|
||||
"development_pass": False,
|
||||
"eligible_for_confirmation": False,
|
||||
"failed_hard_gate_count": edata.get("failed_hard_gate_count", 0),
|
||||
"failed_gates": edata.get("failed_gates", []),
|
||||
"summary_metrics": summary_metrics,
|
||||
"state_ratios": edata.get("state_ratios", {}),
|
||||
"cell_metrics": cell_metrics,
|
||||
"cdata": cdata,
|
||||
"edata": edata,
|
||||
"resources": {
|
||||
"runs": c_runs,
|
||||
"queries": c_queries,
|
||||
"sample_evaluations": c_samples,
|
||||
"wall_time_sec": float(wall_time),
|
||||
"official_test_evaluations": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_all_variants(
|
||||
variant_summaries: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validates cumulative resources across all variants, identifies the best-observed variant,
|
||||
and constructs cumulative summary dictionary.
|
||||
"""
|
||||
observed_ids = tuple(v["variant_id"] for v in variant_summaries)
|
||||
if observed_ids != EXPECTED_VARIANT_IDS:
|
||||
raise ValueError(
|
||||
f"Expected exact development variants {list(EXPECTED_VARIANT_IDS)}, got {list(observed_ids)}"
|
||||
)
|
||||
|
||||
total_runs = sum(v["resources"]["runs"] for v in variant_summaries)
|
||||
total_queries = sum(v["resources"]["queries"] for v in variant_summaries)
|
||||
total_samples = sum(v["resources"]["sample_evaluations"] for v in variant_summaries)
|
||||
official_test_evals = sum(v["resources"]["official_test_evaluations"] for v in variant_summaries)
|
||||
|
||||
wall_time_sec = round(
|
||||
math.fsum(v["resources"]["wall_time_sec"] for v in variant_summaries),
|
||||
4,
|
||||
)
|
||||
if total_runs != EXPECTED_TOTAL_RUNS:
|
||||
raise ValueError(f"Cumulative total runs must be {EXPECTED_TOTAL_RUNS}, got {total_runs}")
|
||||
if total_queries != EXPECTED_TOTAL_QUERIES:
|
||||
raise ValueError(f"Cumulative total queries must be {EXPECTED_TOTAL_QUERIES}, got {total_queries}")
|
||||
if total_samples != EXPECTED_TOTAL_SAMPLES:
|
||||
raise ValueError(f"Cumulative total sample evaluations must be {EXPECTED_TOTAL_SAMPLES}, got {total_samples}")
|
||||
if official_test_evals != 0:
|
||||
raise ValueError(f"Cumulative official test evaluations must be 0, got {official_test_evals}")
|
||||
|
||||
# Mark best-observed-but-rejected variant (highest evaluation score)
|
||||
best_variant = max(variant_summaries, key=lambda v: v["score"])
|
||||
for v in variant_summaries:
|
||||
v["is_best_observed"] = (v["variant_id"] == best_variant["variant_id"])
|
||||
|
||||
return {
|
||||
"n_variants": len(variant_summaries),
|
||||
"total_runs": total_runs,
|
||||
"total_queries": total_queries,
|
||||
"total_sample_evaluations": total_samples,
|
||||
"official_test_evaluations": 0,
|
||||
"best_observed_variant_id": best_variant["variant_id"],
|
||||
"wall_time_sec": wall_time_sec,
|
||||
"best_observed_score": best_variant["score"],
|
||||
}
|
||||
|
||||
|
||||
def build_publish_json(
|
||||
source_dir: Path,
|
||||
variant_summaries: List[Dict[str, Any]],
|
||||
cum_resources: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Constructs the compact JSON dictionary matching all publication criteria.
|
||||
"""
|
||||
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
clean_variants = []
|
||||
for v in sorted(variant_summaries, key=lambda item: item["variant_id"]):
|
||||
clean_variants.append({
|
||||
"variant_id": v["variant_id"],
|
||||
"candidate_file": v["candidate_file"],
|
||||
"evaluation_file": v["evaluation_file"],
|
||||
"phase": v["phase"],
|
||||
"candidate_config": v["candidate_config"],
|
||||
"score": v["score"],
|
||||
"pass": v["pass"],
|
||||
"development_pass": v["development_pass"],
|
||||
"eligible_for_confirmation": v["eligible_for_confirmation"],
|
||||
"failed_hard_gate_count": v["failed_hard_gate_count"],
|
||||
"failed_gates": v["failed_gates"],
|
||||
"is_best_observed": v["is_best_observed"],
|
||||
"summary_metrics": v["summary_metrics"],
|
||||
"state_ratios": v["state_ratios"],
|
||||
"cell_metrics": v["cell_metrics"],
|
||||
"resources": v["resources"],
|
||||
})
|
||||
|
||||
payload = {
|
||||
"protocol_version": PUBLISH_PROTOCOL_VERSION,
|
||||
"pso_version": pso_version,
|
||||
"timestamp": now_iso,
|
||||
"mission_contract": {
|
||||
"phase": "development",
|
||||
"official_test_data_loaded": False,
|
||||
"official_test_evaluations": 0,
|
||||
"confirmation_executed": False,
|
||||
"retained_policy": None,
|
||||
},
|
||||
"official_test_data_loaded": False,
|
||||
"official_test_evaluations": 0,
|
||||
"confirmation_executed": False,
|
||||
"retained_policy": None,
|
||||
"verdict": {
|
||||
"status": "NO_RETAINED_POLICY_NO_CONFIRMATION",
|
||||
"retained_policy": None,
|
||||
"confirmation_executed": False,
|
||||
"official_test_data_loaded": False,
|
||||
"official_test_evaluations": 0,
|
||||
"best_observed_variant_id": cum_resources["best_observed_variant_id"],
|
||||
"best_observed_score": cum_resources["best_observed_score"],
|
||||
"description": (
|
||||
"All 9 development candidates failed the frozen evaluator gates (specifically maximum accuracy "
|
||||
"regression and/or development wide CNN improvement). No candidate qualified for confirmation. "
|
||||
"Confirmation split 20260907 and official test data remained completely unexecuted and sealed."
|
||||
),
|
||||
},
|
||||
"source_provenance": {
|
||||
"source_dir": _repository_relative_path(source_dir),
|
||||
"n_variants": cum_resources["n_variants"],
|
||||
"candidate_files": [v["candidate_file"] for v in clean_variants],
|
||||
"evaluation_files": [v["evaluation_file"] for v in clean_variants],
|
||||
},
|
||||
"cumulative_resources": cum_resources,
|
||||
"total_runs": cum_resources["total_runs"],
|
||||
"total_wall_time_sec": cum_resources["wall_time_sec"],
|
||||
"total_queries": cum_resources["total_queries"],
|
||||
"total_sample_evaluations": cum_resources["total_sample_evaluations"],
|
||||
"variants": clean_variants,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def build_publish_csv(
|
||||
variant_summaries: List[Dict[str, Any]],
|
||||
csv_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Writes CSV summary with header + exactly 72 data rows (9 variants x 2 splits x 4 workloads).
|
||||
Uses deterministic ordering and atomic writing.
|
||||
"""
|
||||
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = [
|
||||
"variant_id",
|
||||
"phase",
|
||||
"split_seed",
|
||||
"workload_id",
|
||||
"baseline_method",
|
||||
"baseline_acc",
|
||||
"candidate_acc",
|
||||
"acc_gain_pp",
|
||||
"baseline_nll",
|
||||
"candidate_nll",
|
||||
"nll_reduction_fraction",
|
||||
"state_ratio",
|
||||
"score",
|
||||
"pass",
|
||||
"is_best_observed",
|
||||
"candidate_path",
|
||||
"evaluation_path",
|
||||
]
|
||||
|
||||
rows = []
|
||||
# Sort variants deterministically
|
||||
sorted_variants = sorted(variant_summaries, key=lambda v: v["variant_id"])
|
||||
|
||||
for v in sorted_variants:
|
||||
dev_ratios = v.get("state_ratios", {}).get("development", {})
|
||||
cell_metrics = v.get("cell_metrics", [])
|
||||
|
||||
# Sort cells deterministically by split_seed then workload_id order
|
||||
def cell_sort_key(cm):
|
||||
wl_idx = WORKLOADS.index(cm["workload_id"]) if cm["workload_id"] in WORKLOADS else 99
|
||||
return (cm["split_seed"], wl_idx)
|
||||
|
||||
sorted_cells = sorted(cell_metrics, key=cell_sort_key)
|
||||
|
||||
for cm in sorted_cells:
|
||||
workload_id = cm["workload_id"]
|
||||
baseline_method = BASELINE_METHODS.get(workload_id, "G8" if "compact" in workload_id else "G5")
|
||||
st_ratio = dev_ratios.get(workload_id, 0.5) if isinstance(dev_ratios, dict) else 0.5
|
||||
|
||||
row = {
|
||||
"variant_id": v["variant_id"],
|
||||
"phase": cm["phase"],
|
||||
"split_seed": cm["split_seed"],
|
||||
"workload_id": workload_id,
|
||||
"baseline_method": baseline_method,
|
||||
"baseline_acc": cm["baseline_acc"],
|
||||
"candidate_acc": cm["candidate_acc"],
|
||||
"acc_gain_pp": cm["acc_gain_pp"],
|
||||
"baseline_nll": cm["baseline_nll"],
|
||||
"candidate_nll": cm["candidate_nll"],
|
||||
"nll_reduction_fraction": cm["nll_reduction_fraction"],
|
||||
"state_ratio": st_ratio,
|
||||
"score": v["score"],
|
||||
"pass": False,
|
||||
"is_best_observed": v["is_best_observed"],
|
||||
"candidate_path": v["candidate_path"],
|
||||
"evaluation_path": v["evaluation_path"],
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
tmp_csv = csv_path.with_suffix(".csv.tmp")
|
||||
with tmp_csv.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
tmp_csv.replace(csv_path)
|
||||
|
||||
def _repository_relative_path(path: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(REPO_ROOT.resolve()))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def render_publish_plot(
|
||||
variant_summaries: List[Dict[str, Any]],
|
||||
plot_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Renders readable 2-panel figure comparing mean gains and worst-cell regressions with frozen thresholds.
|
||||
Clearly marks all variants failed and confirmation withheld.
|
||||
"""
|
||||
plot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sorted_variants = sorted(variant_summaries, key=lambda v: v["variant_id"])
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6.5))
|
||||
|
||||
variant_labels = [
|
||||
v["variant_id"].replace("-development", "").replace("iteration-", "iter-")
|
||||
for v in sorted_variants
|
||||
]
|
||||
x = np.arange(len(variant_labels))
|
||||
width = 0.35
|
||||
|
||||
# Panel 1: Development Grand Mean Performance Gains
|
||||
acc_gains = [v["summary_metrics"].get("development_grand_mean_accuracy_gain_pp", 0.0) for v in sorted_variants]
|
||||
nll_reductions = [v["summary_metrics"].get("development_grand_mean_nll_reduction_fraction", 0.0) * 100.0 for v in sorted_variants]
|
||||
|
||||
ax1.bar(x - width/2, acc_gains, width, label="Grand Mean Acc Gain (pp)", color="#1f77b4")
|
||||
ax1.bar(x + width/2, nll_reductions, width, label="Grand Mean NLL Red. (%)", color="#2ca02c")
|
||||
|
||||
ax1.axhline(0.0, color="black", linestyle="--", linewidth=1.0, alpha=0.7)
|
||||
ax1.set_xticks(x)
|
||||
ax1.set_xticklabels(variant_labels, rotation=35, ha="right", fontsize=9)
|
||||
ax1.set_ylabel("Percentage Points (pp) / Percentage (%)")
|
||||
ax1.set_title("Panel A: Development Grand Mean Performance Gains")
|
||||
ax1.legend(loc="upper left")
|
||||
ax1.grid(True, linestyle="--", alpha=0.4)
|
||||
|
||||
# Panel 2: Worst-Cell Regressions & Wide CNN Improvement vs Gate Thresholds
|
||||
worst_acc_regs = []
|
||||
worst_nll_regs = []
|
||||
mnist_wide_accs = []
|
||||
|
||||
for v in sorted_variants:
|
||||
cell_acc_gains = [cm["acc_gain_pp"] for cm in v["cell_metrics"]]
|
||||
cell_nll_reds = [cm["nll_reduction_fraction"] * 100.0 for cm in v["cell_metrics"]]
|
||||
worst_acc_regs.append(min(cell_acc_gains))
|
||||
worst_nll_regs.append(min(cell_nll_reds))
|
||||
mnist_wide_accs.append(v["summary_metrics"].get("development_mnist_wide_accuracy_gain_pp", 0.0))
|
||||
|
||||
ax2.plot(x, worst_acc_regs, "o-", color="#d62728", linewidth=2, label="Worst-Cell Acc Delta (pp)")
|
||||
ax2.plot(x, worst_nll_regs, "s--", color="#ff7f0e", linewidth=2, label="Worst-Cell NLL Red. (%)")
|
||||
ax2.plot(x, mnist_wide_accs, "^-.", color="#9467bd", linewidth=2, label="MNIST Wide Acc Gain (pp)")
|
||||
|
||||
# Gate threshold lines
|
||||
ax2.axhline(-1.0, color="#d62728", linestyle=":", linewidth=1.5, label="Gate: Max Acc Reg. (-1.0 pp)")
|
||||
ax2.axhline(-5.0, color="#ff7f0e", linestyle=":", linewidth=1.5, label="Gate: Max NLL Reg. (-5.0%)")
|
||||
ax2.axhline(2.0, color="#2ca02c", linestyle="--", linewidth=1.5, label="Gate: MNIST Wide Gain (>= +2 pp)")
|
||||
panel_two_values = worst_acc_regs + worst_nll_regs + mnist_wide_accs + [-5.0, 2.0]
|
||||
panel_two_span = max(panel_two_values) - min(panel_two_values)
|
||||
panel_two_margin = max(1.0, panel_two_span * 0.08)
|
||||
ax2.set_ylim(
|
||||
min(panel_two_values) - panel_two_margin,
|
||||
max(panel_two_values) + panel_two_margin,
|
||||
)
|
||||
|
||||
ax2.set_xticks(x)
|
||||
ax2.set_xticklabels(variant_labels, rotation=35, ha="right", fontsize=9)
|
||||
ax2.set_ylabel("Metrics vs Gate Thresholds")
|
||||
ax2.set_title("Panel B: Worst-Cell Regressions & Wide CNN Improvement vs Gates")
|
||||
ax2.legend(loc="lower left", fontsize=8)
|
||||
ax2.grid(True, linestyle="--", alpha=0.4)
|
||||
|
||||
# Highlight best observed candidate
|
||||
best_idx = next(i for i, v in enumerate(sorted_variants) if v["is_best_observed"])
|
||||
ax1.annotate(
|
||||
f"Best observed, still rejected\nScore: {sorted_variants[best_idx]['score']:.2f}",
|
||||
xy=(best_idx, acc_gains[best_idx]),
|
||||
xycoords="data",
|
||||
xytext=(0.62, 0.94),
|
||||
textcoords="axes fraction",
|
||||
arrowprops=dict(facecolor="black", shrink=0.05, width=1, headwidth=5),
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="black", alpha=0.95),
|
||||
fontsize=8,
|
||||
ha="center",
|
||||
va="top",
|
||||
weight="bold",
|
||||
)
|
||||
|
||||
# Mission Outcome Text Banner
|
||||
fig.suptitle(
|
||||
"HEAVY PSO CROSS-SPLIT ROBUSTNESS MISSION REPORT\n"
|
||||
"STATUS: ALL 9 VARIANTS FAILED FROZEN EVALUATOR GATES | CONFIRMATION WITHHELD & SEALED OFFICIAL TEST UNEXECUTED | RETAINED POLICY: NONE",
|
||||
fontsize=11,
|
||||
weight="bold",
|
||||
color="#8b0000",
|
||||
y=0.99,
|
||||
)
|
||||
|
||||
plt.tight_layout(rect=[0, 0, 1, 0.93])
|
||||
tmp_plot = plot_path.with_name(plot_path.stem + "_tmp.png")
|
||||
fig.savefig(tmp_plot, dpi=200, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
tmp_plot.replace(plot_path)
|
||||
|
||||
|
||||
def publish_heavy_cross_split(
|
||||
source_dir: Union[str, Path] = DEFAULT_SOURCE_DIR,
|
||||
output_json: Union[str, Path] = DEFAULT_JSON_OUTPUT,
|
||||
output_csv: Union[str, Path] = DEFAULT_CSV_OUTPUT,
|
||||
output_plot: Union[str, Path] = DEFAULT_PLOT_OUTPUT,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Main programmatic interface for Heavy PSO Cross-Split results publication.
|
||||
Parses artifacts, validates all contracts and resource totals, and writes
|
||||
the compact JSON, CSV summary, and PNG plot atomically.
|
||||
"""
|
||||
source_dir = Path(source_dir).resolve()
|
||||
output_json = Path(output_json).resolve()
|
||||
output_csv = Path(output_csv).resolve()
|
||||
output_plot = Path(output_plot).resolve()
|
||||
|
||||
pairs = discover_and_load_variants(source_dir)
|
||||
|
||||
variant_summaries = []
|
||||
for cf, ef, cdata, edata in pairs:
|
||||
summary = validate_variant_pair(cf, ef, cdata, edata)
|
||||
variant_summaries.append(summary)
|
||||
|
||||
cum_resources = validate_all_variants(variant_summaries)
|
||||
|
||||
json_payload = build_publish_json(source_dir, variant_summaries, cum_resources)
|
||||
save_json_atomic(json_payload, output_json)
|
||||
|
||||
build_publish_csv(variant_summaries, output_csv)
|
||||
|
||||
render_publish_plot(variant_summaries, output_plot)
|
||||
|
||||
return json_payload
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Publish Heavy PSO Cross-Split Robustness Mission Results"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_SOURCE_DIR,
|
||||
help=f"Raw experiment runs directory (default: {DEFAULT_SOURCE_DIR})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-json",
|
||||
type=Path,
|
||||
default=DEFAULT_JSON_OUTPUT,
|
||||
help=f"Output compact JSON path (default: {DEFAULT_JSON_OUTPUT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-csv",
|
||||
type=Path,
|
||||
default=DEFAULT_CSV_OUTPUT,
|
||||
help=f"Output CSV summary path (default: {DEFAULT_CSV_OUTPUT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-plot",
|
||||
type=Path,
|
||||
default=DEFAULT_PLOT_OUTPUT,
|
||||
help=f"Output PNG plot path (default: {DEFAULT_PLOT_OUTPUT})",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
publish_heavy_cross_split(
|
||||
source_dir=args.source_dir,
|
||||
output_json=args.output_json,
|
||||
output_csv=args.output_csv,
|
||||
output_plot=args.output_plot,
|
||||
)
|
||||
print(f"[{PUBLISH_PROTOCOL_VERSION}] Successfully published cross-split results!")
|
||||
print(f" JSON: {args.output_json}")
|
||||
print(f" CSV: {args.output_csv}")
|
||||
print(f" Plot: {args.output_plot}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
Adaptive Moment 120-Particle x 80-Epoch MNIST Scaling Replication Check
|
||||
|
||||
Validates the published 120-particle x 80-epoch fixed-epoch Adaptive Moment MNIST scaling result.
|
||||
Performs exact replay on seeds 71-75 and fresh independent cohort evaluation on seeds 81-85.
|
||||
|
||||
Predeclared Acceptance Criteria:
|
||||
1. Exact Replay (seeds 71-75): Max per-seed absolute test accuracy delta <= 0.005 (0.5%p).
|
||||
2. Independent Cohort (seeds 81-85): Mean test accuracy absolute difference <= 0.03 (3%p)
|
||||
AND 95% t-confidence intervals overlap between baseline and independent cohorts.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import torch
|
||||
from sklearn.decomposition import PCA
|
||||
|
||||
# Path setup for imports from test/ directory
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from benchmark_suite import (
|
||||
calc_stats,
|
||||
compute_data_fingerprint,
|
||||
get_hardware_provenance,
|
||||
resolve_execution_device,
|
||||
save_json_atomic,
|
||||
)
|
||||
from pso import __version__ as pso_version
|
||||
from tuning_suite import (
|
||||
TUNING_PROTOCOL_VERSION,
|
||||
CandidateConfig,
|
||||
get_mnist_raw_data,
|
||||
get_search_candidates,
|
||||
run_single_experiment,
|
||||
)
|
||||
|
||||
REPLAY_TOLERANCE = 0.005
|
||||
INDEPENDENT_MEAN_MARGIN = 0.03
|
||||
REPLICATION_PROTOCOL_VERSION = "1.0.0"
|
||||
REPLAY_SEEDS = [71, 72, 73, 74, 75]
|
||||
INDEPENDENT_SEEDS = [81, 82, 83, 84, 85]
|
||||
|
||||
|
||||
def validate_and_load_baseline(
|
||||
baseline_path: Path,
|
||||
) -> Tuple[Dict[str, Any], List[Dict[str, Any]], CandidateConfig, str]:
|
||||
if not baseline_path.exists():
|
||||
raise FileNotFoundError(f"Baseline JSON file not found at: {baseline_path}")
|
||||
|
||||
with open(baseline_path, "r", encoding="utf-8") as f:
|
||||
baseline_data = json.load(f)
|
||||
|
||||
if baseline_data.get("tuning_protocol_version") != TUNING_PROTOCOL_VERSION:
|
||||
raise ValueError(
|
||||
"Baseline tuning protocol mismatch: "
|
||||
f"expected {TUNING_PROTOCOL_VERSION}, "
|
||||
f"got {baseline_data.get('tuning_protocol_version')}"
|
||||
)
|
||||
if baseline_data.get("quick") is not False:
|
||||
raise ValueError("Replication requires the full, non-quick tuning baseline.")
|
||||
|
||||
winners = baseline_data.get("winners", {})
|
||||
if "adaptive_moment" not in winners:
|
||||
raise ValueError(f"Baseline JSON {baseline_path} missing 'adaptive_moment' winner entry.")
|
||||
|
||||
am_winner_info = winners["adaptive_moment"]
|
||||
winner_label = am_winner_info.get("candidate_label")
|
||||
|
||||
all_candidates = get_search_candidates()
|
||||
am_candidates = all_candidates.get("adaptive_moment", [])
|
||||
winner_cfg = None
|
||||
for cfg in am_candidates:
|
||||
if cfg.candidate_label == winner_label:
|
||||
winner_cfg = cfg
|
||||
break
|
||||
|
||||
if winner_cfg is None:
|
||||
raise ValueError(
|
||||
f"Could not find CandidateConfig matching label '{winner_label}' in search candidates."
|
||||
)
|
||||
expected_optimizer_config = winner_cfg.to_optimizer_kwargs(quick=False)
|
||||
if am_winner_info.get("config") != expected_optimizer_config:
|
||||
raise ValueError(
|
||||
"Adaptive Moment winner configuration in the baseline no longer matches "
|
||||
f"CandidateConfig '{winner_label}'."
|
||||
)
|
||||
|
||||
|
||||
scaling_runs = baseline_data.get("scaling_runs", [])
|
||||
baseline_records = []
|
||||
for r in scaling_runs:
|
||||
if (
|
||||
r.get("completed")
|
||||
and r.get("method") == "adaptive_moment"
|
||||
and r.get("candidate_label") == winner_label
|
||||
and r.get("n_particles") == 120
|
||||
and r.get("epochs") == 80
|
||||
and r.get("regimen") == "fixed_epoch"
|
||||
and r.get("seed") in REPLAY_SEEDS
|
||||
):
|
||||
baseline_records.append(r)
|
||||
|
||||
baseline_records.sort(key=lambda x: x["seed"])
|
||||
|
||||
if len(baseline_records) != 5:
|
||||
raise ValueError(
|
||||
f"Expected exactly 5 baseline records for seeds {REPLAY_SEEDS}, "
|
||||
f"found {len(baseline_records)} in {baseline_path}."
|
||||
)
|
||||
|
||||
expected_seeds = sorted(REPLAY_SEEDS)
|
||||
actual_seeds = [r["seed"] for r in baseline_records]
|
||||
if actual_seeds != expected_seeds:
|
||||
raise ValueError(f"Baseline seeds mismatch: expected {expected_seeds}, got {actual_seeds}")
|
||||
|
||||
expected_fp = baseline_data.get("split_fingerprints", {}).get("full")
|
||||
if not isinstance(expected_fp, str) or not expected_fp:
|
||||
raise ValueError("Baseline JSON is missing split_fingerprints.full.")
|
||||
for r in baseline_records:
|
||||
if r.get("data_fingerprint") != expected_fp:
|
||||
raise ValueError(
|
||||
f"Baseline run seed {r['seed']} data_fingerprint {r.get('data_fingerprint')} "
|
||||
f"does not match split_fingerprints.full {expected_fp}"
|
||||
)
|
||||
run_config = r.get("config", {})
|
||||
for key, value in expected_optimizer_config.items():
|
||||
if run_config.get(key) != value:
|
||||
raise ValueError(
|
||||
f"Baseline run seed {r['seed']} config[{key!r}]={run_config.get(key)!r} "
|
||||
f"does not match selected winner value {value!r}."
|
||||
)
|
||||
expected_run_config = {
|
||||
"n_particles": 120,
|
||||
"epochs": 80,
|
||||
"batch_size": 1000,
|
||||
"renewal": "loss",
|
||||
}
|
||||
for key, value in expected_run_config.items():
|
||||
if run_config.get(key) != value:
|
||||
raise ValueError(
|
||||
f"Baseline run seed {r['seed']} config[{key!r}]={run_config.get(key)!r}; "
|
||||
f"expected {value!r}."
|
||||
)
|
||||
|
||||
return baseline_data, baseline_records, winner_cfg, expected_fp
|
||||
|
||||
|
||||
def prepare_full_pca_data() -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, str]:
|
||||
x_train_raw, x_test_raw, y_train_3000, y_test_1000 = get_mnist_raw_data()
|
||||
pca_full = PCA(n_components=32, whiten=True, random_state=42)
|
||||
x_full_tr = torch.tensor(pca_full.fit_transform(x_train_raw), dtype=torch.float32)
|
||||
x_full_test = torch.tensor(pca_full.transform(x_test_raw), dtype=torch.float32)
|
||||
data_fp = compute_data_fingerprint(x_full_tr, x_full_test, y_train_3000, y_test_1000)
|
||||
return x_full_tr, y_train_3000, x_full_test, y_test_1000, data_fp
|
||||
|
||||
|
||||
def run_replication_cohort(
|
||||
cfg: CandidateConfig,
|
||||
seeds: List[int],
|
||||
x_train: torch.Tensor,
|
||||
y_train: torch.Tensor,
|
||||
x_eval: torch.Tensor,
|
||||
y_eval: torch.Tensor,
|
||||
device: torch.device,
|
||||
data_fp: str,
|
||||
run_type: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
runs = []
|
||||
for seed in seeds:
|
||||
res = run_single_experiment(
|
||||
cfg=cfg,
|
||||
seed=seed,
|
||||
x_train=x_train,
|
||||
y_train=y_train,
|
||||
x_eval=x_eval,
|
||||
y_eval=y_eval,
|
||||
n_particles=120,
|
||||
epochs=80,
|
||||
batch_size=1000,
|
||||
device=device,
|
||||
quick=False,
|
||||
eval_metric_name="test",
|
||||
data_fp=data_fp,
|
||||
run_type=run_type,
|
||||
extra_meta={"regimen": "fixed_epoch"},
|
||||
)
|
||||
runs.append(res)
|
||||
return runs
|
||||
|
||||
|
||||
def evaluate_replication(
|
||||
baseline_records: List[Dict[str, Any]],
|
||||
replay_runs: List[Dict[str, Any]],
|
||||
independent_runs: List[Dict[str, Any]],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, float], Dict[str, float], Dict[str, float]]:
|
||||
base_acc_by_seed = {r["seed"]: float(r["test_acc"]) for r in baseline_records}
|
||||
replay_acc_by_seed = {r["seed"]: float(r["test_acc"]) for r in replay_runs}
|
||||
expected_seeds = set(REPLAY_SEEDS)
|
||||
if set(base_acc_by_seed) != expected_seeds or set(replay_acc_by_seed) != expected_seeds:
|
||||
raise ValueError("Baseline and replay cohorts must each contain exactly seeds 71-75.")
|
||||
if len(independent_runs) != len(INDEPENDENT_SEEDS) or {
|
||||
r["seed"] for r in independent_runs
|
||||
} != set(INDEPENDENT_SEEDS):
|
||||
raise ValueError("Independent cohort must contain exactly seeds 81-85.")
|
||||
|
||||
baseline_model_fp = {r["seed"]: r.get("model_fingerprint") for r in baseline_records}
|
||||
replay_model_fp = {r["seed"]: r.get("model_fingerprint") for r in replay_runs}
|
||||
replay_model_fingerprint_match = baseline_model_fp == replay_model_fp
|
||||
replay_deltas = {}
|
||||
max_replay_delta = 0.0
|
||||
for seed in sorted(base_acc_by_seed.keys()):
|
||||
b_acc = base_acc_by_seed[seed]
|
||||
r_acc = replay_acc_by_seed[seed]
|
||||
delta = abs(r_acc - b_acc)
|
||||
replay_deltas[str(seed)] = round(delta, 6)
|
||||
if delta > max_replay_delta:
|
||||
max_replay_delta = delta
|
||||
|
||||
replay_pass = bool(max_replay_delta <= REPLAY_TOLERANCE)
|
||||
|
||||
baseline_accs = [base_acc_by_seed[s] for s in sorted(base_acc_by_seed.keys())]
|
||||
replay_accs = [replay_acc_by_seed[s] for s in sorted(replay_acc_by_seed.keys())]
|
||||
indep_accs = [float(r["test_acc"]) for r in independent_runs]
|
||||
|
||||
baseline_stats = calc_stats(baseline_accs)
|
||||
replay_stats = calc_stats(replay_accs)
|
||||
independent_stats = calc_stats(indep_accs)
|
||||
|
||||
indep_mean_diff = abs(independent_stats["mean"] - baseline_stats["mean"])
|
||||
independent_mean_pass = bool(indep_mean_diff <= INDEPENDENT_MEAN_MARGIN)
|
||||
|
||||
baseline_ci_low = round(baseline_stats["mean"] - baseline_stats["ci95_t"], 6)
|
||||
baseline_ci_high = round(baseline_stats["mean"] + baseline_stats["ci95_t"], 6)
|
||||
|
||||
indep_ci_low = round(independent_stats["mean"] - independent_stats["ci95_t"], 6)
|
||||
indep_ci_high = round(independent_stats["mean"] + independent_stats["ci95_t"], 6)
|
||||
|
||||
ci_overlap_pass = bool(max(baseline_ci_low, indep_ci_low) <= min(baseline_ci_high, indep_ci_high))
|
||||
independent_pass = bool(independent_mean_pass and ci_overlap_pass)
|
||||
|
||||
comparison = {
|
||||
"replay_per_seed_deltas": replay_deltas,
|
||||
"replay_max_abs_delta": round(max_replay_delta, 6),
|
||||
"replay_model_fingerprint_match": replay_model_fingerprint_match,
|
||||
"replay_pass": bool(replay_pass and replay_model_fingerprint_match),
|
||||
"independent_mean_abs_diff": round(indep_mean_diff, 6),
|
||||
"independent_mean_pass": independent_mean_pass,
|
||||
"baseline_ci95_t_interval": [baseline_ci_low, baseline_ci_high],
|
||||
"independent_ci95_t_interval": [indep_ci_low, indep_ci_high],
|
||||
"ci_overlap_pass": ci_overlap_pass,
|
||||
"independent_pass": independent_pass,
|
||||
"overall_pass": bool(
|
||||
replay_pass and replay_model_fingerprint_match and independent_pass
|
||||
),
|
||||
}
|
||||
|
||||
return comparison, baseline_stats, replay_stats, independent_stats
|
||||
|
||||
|
||||
def write_replication_csv(
|
||||
baseline_records: List[Dict[str, Any]],
|
||||
replay_runs: List[Dict[str, Any]],
|
||||
independent_runs: List[Dict[str, Any]],
|
||||
output_csv: Path,
|
||||
):
|
||||
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
fields = [
|
||||
"cohort",
|
||||
"method",
|
||||
"candidate_label",
|
||||
"regimen",
|
||||
"seed",
|
||||
"n_particles",
|
||||
"epochs",
|
||||
"particle_epochs",
|
||||
"train_loss",
|
||||
"train_acc",
|
||||
"test_loss",
|
||||
"test_acc",
|
||||
"test_mse",
|
||||
"fit_time_sec",
|
||||
"data_fingerprint",
|
||||
"model_fingerprint",
|
||||
"device",
|
||||
"completed",
|
||||
"error",
|
||||
]
|
||||
all_rows = []
|
||||
for r in baseline_records:
|
||||
row = dict(r)
|
||||
row["cohort"] = "baseline"
|
||||
all_rows.append(row)
|
||||
for r in replay_runs:
|
||||
row = dict(r)
|
||||
row["cohort"] = "replay"
|
||||
all_rows.append(row)
|
||||
for r in independent_runs:
|
||||
row = dict(r)
|
||||
row["cohort"] = "independent"
|
||||
all_rows.append(row)
|
||||
|
||||
with open(output_csv, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for r in all_rows:
|
||||
writer.writerow(r)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Replicate and verify published Adaptive Moment 120p x 80e MNIST scaling result"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_tuning.json"),
|
||||
help="Path to baseline tuning JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-json",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_120p80_replication.json"),
|
||||
help="Path for replication output JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-csv",
|
||||
type=Path,
|
||||
default=Path("benchmark_results/pso_v4_120p80_replication.csv"),
|
||||
help="Path for replication output CSV",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Execution device (cpu, cuda, mps)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
device = resolve_execution_device(args.device)
|
||||
|
||||
print("=== Adaptive Moment 120p x 80e Replication Check ===")
|
||||
print(f"Device: {device}")
|
||||
print(f"Baseline JSON: {args.baseline_json}")
|
||||
print(f"Output JSON: {args.output_json}")
|
||||
print(f"Output CSV: {args.output_csv}")
|
||||
|
||||
baseline_data, baseline_records, winner_cfg, expected_fp = validate_and_load_baseline(
|
||||
args.baseline_json
|
||||
)
|
||||
print(f"Validated baseline winner '{winner_cfg.candidate_label}' across 5 records.")
|
||||
if baseline_data.get("device") != str(device):
|
||||
raise ValueError(
|
||||
f"Exact replay requires baseline device {baseline_data.get('device')!r}; "
|
||||
f"got {str(device)!r}."
|
||||
)
|
||||
if baseline_data.get("pso_version") != pso_version:
|
||||
raise ValueError(
|
||||
f"Exact replay requires pso version {baseline_data.get('pso_version')!r}; "
|
||||
f"got {pso_version!r}."
|
||||
)
|
||||
if baseline_data.get("torch_version") != torch.__version__:
|
||||
raise ValueError(
|
||||
f"Exact replay requires torch version {baseline_data.get('torch_version')!r}; "
|
||||
f"got {torch.__version__!r}."
|
||||
)
|
||||
|
||||
x_full_tr, y_train_3000, x_full_test, y_test_1000, data_fp = prepare_full_pca_data()
|
||||
if expected_fp and data_fp != expected_fp:
|
||||
raise ValueError(
|
||||
f"Reconstructed data fingerprint {data_fp} does not match baseline {expected_fp}"
|
||||
)
|
||||
print(f"Reconstructed PCA32 data (fingerprint: {data_fp})")
|
||||
|
||||
print("\n--- Running Cohort 1: Exact Replay (Seeds 71-75) ---")
|
||||
replay_runs = run_replication_cohort(
|
||||
cfg=winner_cfg,
|
||||
seeds=REPLAY_SEEDS,
|
||||
x_train=x_full_tr,
|
||||
y_train=y_train_3000,
|
||||
x_eval=x_full_test,
|
||||
y_eval=y_test_1000,
|
||||
device=device,
|
||||
data_fp=data_fp,
|
||||
run_type="replication_replay",
|
||||
)
|
||||
|
||||
print("\n--- Running Cohort 2: Independent Fresh Seeds (Seeds 81-85) ---")
|
||||
independent_runs = run_replication_cohort(
|
||||
cfg=winner_cfg,
|
||||
seeds=INDEPENDENT_SEEDS,
|
||||
x_train=x_full_tr,
|
||||
y_train=y_train_3000,
|
||||
x_eval=x_full_test,
|
||||
y_eval=y_test_1000,
|
||||
device=device,
|
||||
data_fp=data_fp,
|
||||
run_type="replication_independent",
|
||||
)
|
||||
|
||||
comparison, baseline_stats, replay_stats, independent_stats = evaluate_replication(
|
||||
baseline_records, replay_runs, independent_runs
|
||||
)
|
||||
|
||||
payload = {
|
||||
"replication_protocol_version": REPLICATION_PROTOCOL_VERSION,
|
||||
"source_tuning_protocol_version": baseline_data["tuning_protocol_version"],
|
||||
"pso_version": pso_version,
|
||||
"torch_version": torch.__version__,
|
||||
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"device": str(device),
|
||||
"hardware": get_hardware_provenance(device),
|
||||
"baseline_json": str(args.baseline_json),
|
||||
"source_tuning_timestamp": baseline_data.get("timestamp"),
|
||||
"candidate_label": winner_cfg.candidate_label,
|
||||
"config": winner_cfg.to_optimizer_kwargs(),
|
||||
"data_fingerprint": data_fp,
|
||||
"criteria": {
|
||||
"replay_seeds": REPLAY_SEEDS,
|
||||
"replay_max_abs_delta_tolerance": REPLAY_TOLERANCE,
|
||||
"require_replay_model_fingerprint_match": True,
|
||||
"independent_seeds": INDEPENDENT_SEEDS,
|
||||
"independent_mean_abs_diff_margin": INDEPENDENT_MEAN_MARGIN,
|
||||
"require_ci_overlap": True,
|
||||
},
|
||||
"summaries": {
|
||||
"baseline": baseline_stats,
|
||||
"replay": replay_stats,
|
||||
"independent": independent_stats,
|
||||
},
|
||||
"comparison": comparison,
|
||||
"baseline_runs": baseline_records,
|
||||
"replay_runs": replay_runs,
|
||||
"independent_runs": independent_runs,
|
||||
"completed": True,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
save_json_atomic(payload, args.output_json)
|
||||
write_replication_csv(baseline_records, replay_runs, independent_runs, args.output_csv)
|
||||
|
||||
print("\n=== Replication Results Summary ===")
|
||||
print(f"Baseline Mean Test Acc: {baseline_stats['mean']:.4f} ± {baseline_stats['std']:.4f}")
|
||||
print(f"Replay Mean Test Acc: {replay_stats['mean']:.4f} ± {replay_stats['std']:.4f}")
|
||||
print(f"Independent Mean Test Acc: {independent_stats['mean']:.4f} ± {independent_stats['std']:.4f}")
|
||||
print(f"Max Replay Delta: {comparison['replay_max_abs_delta']:.6f} (Limit: {REPLAY_TOLERANCE}) -> Pass: {comparison['replay_pass']}")
|
||||
print(f"Indep Mean Diff: {comparison['independent_mean_abs_diff']:.6f} (Limit: {INDEPENDENT_MEAN_MARGIN}) -> Pass: {comparison['independent_mean_pass']}")
|
||||
print(f"CI Overlap Pass: {comparison['ci_overlap_pass']} (Baseline CI: {comparison['baseline_ci95_t_interval']}, Indep CI: {comparison['independent_ci95_t_interval']})")
|
||||
print(f"OVERALL PASS: {comparison['overall_pass']}")
|
||||
|
||||
if not comparison["overall_pass"]:
|
||||
print("\nREPLICATION CHECK FAILED!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nREPLICATION CHECK PASSED!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+97
-81
@@ -1,23 +1,16 @@
|
||||
# %%
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import tensorflow as tf
|
||||
from keras.layers import Dense
|
||||
from keras.models import Sequential
|
||||
from keras.utils import to_categorical
|
||||
import argparse
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from sklearn.model_selection import train_test_split
|
||||
from tensorflow import keras
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from pso import optimizer
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
from pso import Optimizer
|
||||
from cli import add_pso_args, build_optimizer_kwargs
|
||||
|
||||
|
||||
def get_data():
|
||||
def get_data(seed: int = 42):
|
||||
with open("data/seeds/seeds_dataset.txt", "r", encoding="utf-8") as f:
|
||||
data = f.readlines()
|
||||
df = pd.DataFrame([d.split() for d in data])
|
||||
@@ -33,80 +26,103 @@ def get_data():
|
||||
]
|
||||
|
||||
df = df.astype(float)
|
||||
df["target"] = df["target"].astype(int)
|
||||
df["target"] = df["target"].astype(int) - 1
|
||||
|
||||
x = df.iloc[:, :-1].values.round(0).astype(int)
|
||||
y = df.iloc[:, -1].values
|
||||
|
||||
y_class = to_categorical(y)
|
||||
x = df.iloc[:, :-1].values.astype(np.float32)
|
||||
y = df.iloc[:, -1].values.astype(np.int64)
|
||||
|
||||
x_train, x_test, y_train, y_test = train_test_split(
|
||||
x, y_class, test_size=0.2, shuffle=True
|
||||
x, y, test_size=0.2, shuffle=True, random_state=seed
|
||||
)
|
||||
scaler = StandardScaler()
|
||||
x_train = scaler.fit_transform(x_train)
|
||||
x_test = scaler.transform(x_test)
|
||||
|
||||
return (
|
||||
torch.tensor(x_train, dtype=torch.float32),
|
||||
torch.tensor(y_train, dtype=torch.int64),
|
||||
torch.tensor(x_test, dtype=torch.float32),
|
||||
torch.tensor(y_test, dtype=torch.int64),
|
||||
)
|
||||
|
||||
return x_train, y_train, x_test, y_test
|
||||
|
||||
def make_model(seed: int = 42):
|
||||
torch.manual_seed(seed)
|
||||
return nn.Sequential(
|
||||
nn.Linear(7, 16),
|
||||
nn.ReLU(),
|
||||
nn.Linear(16, 32),
|
||||
nn.ReLU(),
|
||||
nn.Linear(32, 3),
|
||||
)
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(Dense(16, activation="relu", input_shape=(7,)))
|
||||
model.add(Dense(32, activation="relu"))
|
||||
model.add(Dense(4, activation="softmax"))
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PSO Seeds Benchmark Script")
|
||||
add_pso_args(
|
||||
parser,
|
||||
defaults={
|
||||
"method": "original",
|
||||
"initialization": "model_noise",
|
||||
"evaluation": "full",
|
||||
"convergence": "particle_reset",
|
||||
"refinement": "adam",
|
||||
"n_particles": 24,
|
||||
"c0": None,
|
||||
"c1": None,
|
||||
"w_min": None,
|
||||
"w_max": None,
|
||||
"negative_swarm": 0.0,
|
||||
"mutation_swarm": 0.3,
|
||||
"particle_min": -3.0,
|
||||
"particle_max": 3.0,
|
||||
"velocity_limit_ratio": 0.1,
|
||||
"boundary_strategy": "reflect",
|
||||
"seed": 42,
|
||||
"epochs": 80,
|
||||
"renewal": "acc",
|
||||
"output_dir": "output/seeds",
|
||||
"checkpoint_interval": 25,
|
||||
"refinement_epochs": 10,
|
||||
"refinement_lr": 0.001,
|
||||
},
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
return model
|
||||
model = make_model(seed=args.seed)
|
||||
x_train, y_train, x_test, y_test = get_data(seed=args.seed)
|
||||
|
||||
fitness_size = args.fitness_size if args.evaluation == "fixed_subset" else None
|
||||
refinement_epochs = args.refinement_epochs if args.refinement == "adam" else 0
|
||||
|
||||
kwargs = build_optimizer_kwargs(
|
||||
args,
|
||||
model=model,
|
||||
loss=nn.CrossEntropyLoss(),
|
||||
task="multiclass",
|
||||
inertia_profile={"c0": 0.5, "c1": 1.0, "w_min": 0.7, "w_max": 1.2},
|
||||
)
|
||||
pso_seeds = Optimizer(**kwargs)
|
||||
|
||||
print(f"Optimizer device: {pso_seeds.device}")
|
||||
|
||||
best_score = pso_seeds.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=fitness_size,
|
||||
renewal=args.renewal,
|
||||
validation_data=(x_test, y_test),
|
||||
output_dir=args.output_dir,
|
||||
checkpoint_interval=25,
|
||||
save_info=True,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
|
||||
print(f"Done! Best score: {best_score}")
|
||||
|
||||
|
||||
# %%
|
||||
model = make_model()
|
||||
x_train, y_train, x_test, y_test = get_data()
|
||||
|
||||
loss = [
|
||||
"mean_squared_error",
|
||||
"categorical_crossentropy",
|
||||
"sparse_categorical_crossentropy",
|
||||
"binary_crossentropy",
|
||||
"kullback_leibler_divergence",
|
||||
"poisson",
|
||||
"cosine_similarity",
|
||||
"log_cosh",
|
||||
"huber_loss",
|
||||
"mean_absolute_error",
|
||||
"mean_absolute_percentage_error",
|
||||
]
|
||||
|
||||
# rs = random_state()
|
||||
|
||||
pso_mnist = optimizer(
|
||||
model,
|
||||
loss="categorical_crossentropy",
|
||||
n_particles=100,
|
||||
c0=0.5,
|
||||
c1=1.0,
|
||||
w_min=0.7,
|
||||
w_max=1.2,
|
||||
negative_swarm=0.0,
|
||||
mutation_swarm=0.3,
|
||||
convergence_reset=True,
|
||||
convergence_reset_patience=10,
|
||||
convergence_reset_monitor="mse",
|
||||
convergence_reset_min_delta=0.0005,
|
||||
)
|
||||
|
||||
best_score = pso_mnist.fit(
|
||||
x_train,
|
||||
y_train,
|
||||
epochs=500,
|
||||
save_info=True,
|
||||
log=2,
|
||||
log_name="seeds",
|
||||
renewal="acc",
|
||||
check_point=25,
|
||||
empirical_balance=False,
|
||||
dispersion=False,
|
||||
back_propagation=False,
|
||||
validate_data=(x_test, y_test),
|
||||
)
|
||||
|
||||
print("Done!")
|
||||
|
||||
sys.exit(0)
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+77
-64
@@ -1,76 +1,89 @@
|
||||
# %%
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
||||
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
from tensorflow import keras
|
||||
from tensorflow.keras import layers
|
||||
from tensorflow.keras.layers import Dense
|
||||
from tensorflow.keras.models import Sequential
|
||||
|
||||
from pso import optimizer
|
||||
from pso import Optimizer
|
||||
from cli import add_pso_args, build_optimizer_kwargs
|
||||
|
||||
|
||||
def get_data():
|
||||
x = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
|
||||
y = np.array([[0], [1], [1], [0]])
|
||||
x = torch.tensor([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]], dtype=torch.float32)
|
||||
y = torch.tensor([[0.0], [1.0], [1.0], [0.0]], dtype=torch.float32)
|
||||
return x, y
|
||||
|
||||
|
||||
def make_model():
|
||||
model = Sequential()
|
||||
model.add(layers.Dense(2, activation="sigmoid", input_shape=(2,)))
|
||||
model.add(layers.Dense(1, activation="sigmoid"))
|
||||
|
||||
return model
|
||||
def make_model(seed: int = 101):
|
||||
torch.manual_seed(seed)
|
||||
return nn.Sequential(
|
||||
nn.Linear(2, 4),
|
||||
nn.Tanh(),
|
||||
nn.Linear(4, 1),
|
||||
)
|
||||
|
||||
|
||||
# %%
|
||||
model = make_model()
|
||||
x_test, y_test = get_data()
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PSO XOR Benchmark Script")
|
||||
add_pso_args(
|
||||
parser,
|
||||
defaults={
|
||||
"method": "original",
|
||||
"initialization": "model_noise",
|
||||
"evaluation": "fixed_subset",
|
||||
"convergence": "none",
|
||||
"refinement": "adam",
|
||||
"n_particles": 40,
|
||||
"c0": None,
|
||||
"c1": None,
|
||||
"w_min": None,
|
||||
"w_max": None,
|
||||
"negative_swarm": 0.1,
|
||||
"mutation_swarm": 0.03,
|
||||
"particle_min": -5.0,
|
||||
"particle_max": 5.0,
|
||||
"velocity_limit_ratio": 0.1,
|
||||
"boundary_strategy": "reflect",
|
||||
"initial_position_noise": 1.0,
|
||||
"seed": 101,
|
||||
"epochs": 120,
|
||||
"fitness_size": 4,
|
||||
"renewal": "loss",
|
||||
"output_dir": "output/xor",
|
||||
"refinement_epochs": 100,
|
||||
"refinement_lr": 0.03,
|
||||
},
|
||||
)
|
||||
args = parser.parse_args()
|
||||
x, y = get_data()
|
||||
model = make_model(seed=args.seed)
|
||||
|
||||
loss = [
|
||||
"mean_squared_error",
|
||||
"mean_squared_logarithmic_error",
|
||||
"binary_crossentropy",
|
||||
"categorical_crossentropy",
|
||||
"sparse_categorical_crossentropy",
|
||||
"kullback_leibler_divergence",
|
||||
"poisson",
|
||||
"cosine_similarity",
|
||||
"log_cosh",
|
||||
"huber_loss",
|
||||
"mean_absolute_error",
|
||||
"mean_absolute_percentage_error",
|
||||
]
|
||||
fitness_size = args.fitness_size if args.evaluation == "fixed_subset" else None
|
||||
refinement_epochs = args.refinement_epochs if args.refinement == "adam" else 0
|
||||
|
||||
pso_xor = optimizer(
|
||||
model,
|
||||
loss=loss[0],
|
||||
n_particles=100,
|
||||
c0=0.35,
|
||||
c1=0.8,
|
||||
w_min=0.6,
|
||||
w_max=1.2,
|
||||
negative_swarm=0.1,
|
||||
mutation_swarm=0.2,
|
||||
particle_min=-3,
|
||||
particle_max=3,
|
||||
)
|
||||
best_score = pso_xor.fit(
|
||||
x_test,
|
||||
y_test,
|
||||
epochs=200,
|
||||
save_info=True,
|
||||
log=2,
|
||||
log_name="xor",
|
||||
renewal="acc",
|
||||
check_point=25,
|
||||
)
|
||||
kwargs = build_optimizer_kwargs(
|
||||
args,
|
||||
model=model,
|
||||
loss=nn.BCEWithLogitsLoss(),
|
||||
task="binary",
|
||||
inertia_profile={"c0": 0.7, "c1": 0.9, "w_min": 0.3, "w_max": 0.8},
|
||||
)
|
||||
pso_xor = Optimizer(**kwargs)
|
||||
print(f"Optimizer device: {pso_xor.device}")
|
||||
|
||||
print("Done!")
|
||||
sys.exit(0)
|
||||
# %%
|
||||
best_score = pso_xor.fit(
|
||||
x,
|
||||
y,
|
||||
epochs=args.epochs,
|
||||
batch_size=args.batch_size,
|
||||
fitness_size=fitness_size,
|
||||
renewal=args.renewal,
|
||||
output_dir=args.output_dir,
|
||||
save_info=True,
|
||||
refinement_epochs=refinement_epochs,
|
||||
refinement_lr=args.refinement_lr,
|
||||
)
|
||||
|
||||
print(f"Done! Best score: {best_score}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user