mirror of
https://github.com/jung-geun/PSO.git
synced 2026-09-20 14:11:48 +09:00
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
1629 lines
55 KiB
Python
1629 lines
55 KiB
Python
import collections
|
|
import json
|
|
import math
|
|
import os
|
|
import pytest
|
|
import torch
|
|
import torch.nn as nn
|
|
|
|
import pso
|
|
from pso.optimizer import Optimizer, resolve_device
|
|
from pso.particle import Particle
|
|
|
|
def test_basic_fit_and_inspection_contract(model_factory, xor_data):
|
|
"""Verify inspection methods return None before fit, and valid objects after fit."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
|
|
# Before fit
|
|
assert opt.get_best_model() is None
|
|
assert opt.get_best_score() is None
|
|
assert opt.get_best_state_dict() is None
|
|
|
|
score = opt.fit(x, y, epochs=2)
|
|
|
|
# Fit return value check
|
|
assert isinstance(score, tuple)
|
|
assert len(score) == 3
|
|
assert all(isinstance(val, float) and math.isfinite(val) for val in score)
|
|
|
|
# get_best_score check
|
|
best_score = opt.get_best_score()
|
|
assert best_score == score
|
|
|
|
# get_best_model check
|
|
best_model = opt.get_best_model()
|
|
assert isinstance(best_model, nn.Module)
|
|
assert not best_model.training # Model is in eval mode
|
|
|
|
# get_best_state_dict check
|
|
state_dict = opt.get_best_state_dict()
|
|
assert isinstance(state_dict, collections.OrderedDict)
|
|
|
|
# All state dict tensors are CPU clones
|
|
for k, v in state_dict.items():
|
|
assert isinstance(v, torch.Tensor)
|
|
assert v.device.type == "cpu"
|
|
|
|
# Mutating returned model parameters does NOT mutate stored state dict
|
|
for p in best_model.parameters():
|
|
p.data.add_(1.0)
|
|
|
|
fresh_state_dict = opt.get_best_state_dict()
|
|
assert fresh_state_dict is not None
|
|
for k in state_dict:
|
|
assert torch.equal(state_dict[k], fresh_state_dict[k])
|
|
|
|
|
|
def test_seeded_reproducibility_and_swarm_variability(model_factory, xor_data):
|
|
"""Verify seeded runs produce identical results while individual swarm particles vary."""
|
|
x, y = xor_data
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
m1 = model_factory()
|
|
m2 = model_factory()
|
|
|
|
opt1 = Optimizer(m1, loss, task="binary", n_particles=3, seed=42)
|
|
opt2 = Optimizer(m2, loss, task="binary", n_particles=3, seed=42)
|
|
|
|
score1 = opt1.fit(x, y, epochs=3)
|
|
score2 = opt2.fit(x, y, epochs=3)
|
|
|
|
assert score1 == score2
|
|
|
|
sd1 = opt1.get_best_state_dict()
|
|
sd2 = opt2.get_best_state_dict()
|
|
assert sd1 is not None and sd2 is not None
|
|
for k in sd1:
|
|
assert torch.equal(sd1[k], sd2[k])
|
|
|
|
# Swarm variability within opt1
|
|
p0 = opt1.particles[0]
|
|
p1 = opt1.particles[1]
|
|
assert not torch.equal(p0.velocity, p1.velocity)
|
|
assert not torch.equal(p0.position, p1.position)
|
|
|
|
|
|
def test_sequential_optimizers_independence(model_factory, xor_data):
|
|
"""Verify sequential optimizers with different model shapes do not leak state."""
|
|
x, y = xor_data
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
m4 = model_factory(units=4)
|
|
opt4 = Optimizer(m4, loss, task="binary", n_particles=2, seed=42)
|
|
opt4.fit(x, y, epochs=2)
|
|
sd4 = opt4.get_best_state_dict()
|
|
assert sd4 is not None
|
|
|
|
m8 = model_factory(units=8)
|
|
opt8 = Optimizer(m8, loss, task="binary", n_particles=2, seed=42)
|
|
opt8.fit(x, y, epochs=2)
|
|
sd8 = opt8.get_best_state_dict()
|
|
assert sd8 is not None
|
|
|
|
assert sd4["0.weight"].shape == torch.Size([4, 2])
|
|
assert sd8["0.weight"].shape == torch.Size([8, 2])
|
|
|
|
def test_multi_batch_evaluation_contract(model_factory, xor_data, monkeypatch):
|
|
"""Verify all particles evaluate identical batch tensors in particle-outer order during swarm iterations."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42)
|
|
|
|
recorded_batches = []
|
|
original_eval = opt._evaluate_batch_tensors
|
|
|
|
def mock_eval(x_batch, y_batch):
|
|
recorded_batches.append((x_batch.detach().cpu(), y_batch.detach().cpu()))
|
|
return original_eval(x_batch, y_batch)
|
|
|
|
monkeypatch.setattr(opt, "_evaluate_batch_tensors", mock_eval)
|
|
|
|
opt.fit(x, y, epochs=1, batch_size=2)
|
|
|
|
# 4 samples, batch_size 2 -> 2 batches
|
|
# 3 particles evaluated over 2 batches -> 6 recorded calls total
|
|
assert len(recorded_batches) == 6
|
|
|
|
# Particle-outer order: Particle 0 (calls 0, 1), Particle 1 (calls 2, 3), Particle 2 (calls 4, 5)
|
|
# Batch 0 (calls 0, 2, 4) must receive identical x_batch and y_batch
|
|
assert torch.equal(recorded_batches[0][0], recorded_batches[2][0])
|
|
assert torch.equal(recorded_batches[0][0], recorded_batches[4][0])
|
|
assert torch.equal(recorded_batches[0][1], recorded_batches[2][1])
|
|
assert torch.equal(recorded_batches[0][1], recorded_batches[4][1])
|
|
|
|
# Batch 1 (calls 1, 3, 5) must receive identical x_batch and y_batch
|
|
assert torch.equal(recorded_batches[1][0], recorded_batches[3][0])
|
|
assert torch.equal(recorded_batches[1][0], recorded_batches[5][0])
|
|
|
|
|
|
def test_zero_loss_succeeds_and_contextual_nonfinite_raises(model_factory, xor_data, monkeypatch):
|
|
x, y = xor_data
|
|
|
|
# Zero init model -> deterministic constant output
|
|
model_zero = model_factory(zero_init=True)
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt_zero = Optimizer(model_zero, loss, task="binary", n_particles=2, seed=42)
|
|
|
|
score_zero = opt_zero.fit(x, y, epochs=2)
|
|
assert all(math.isfinite(val) for val in score_zero)
|
|
|
|
# Non-finite score raises FloatingPointError
|
|
model = model_factory()
|
|
opt_nan = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
|
|
monkeypatch.setattr(
|
|
opt_nan,
|
|
"_evaluate_batch_tensors",
|
|
lambda x_batch, y_batch: (
|
|
torch.tensor(float("nan")),
|
|
torch.tensor(0.0),
|
|
torch.tensor(float("nan")),
|
|
),
|
|
)
|
|
|
|
with pytest.raises(FloatingPointError) as exc_info:
|
|
opt_nan.fit(x, y, epochs=1)
|
|
|
|
err_msg = str(exc_info.value).lower()
|
|
assert "particle" in err_msg
|
|
assert "iteration" in err_msg
|
|
|
|
|
|
def test_hard_bounds_and_velocity_reflection(model_factory, xor_data):
|
|
"""Verify position clipping and boundary reflection enforce hard particle_min and particle_max bounds."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
p_min, p_max = -0.1, 0.1
|
|
v_ratio = 0.5
|
|
span = p_max - p_min
|
|
max_vel = v_ratio * span
|
|
|
|
# Boundary strategy clip
|
|
opt_clip = Optimizer(
|
|
model,
|
|
loss,
|
|
task="binary",
|
|
n_particles=3,
|
|
particle_min=p_min,
|
|
particle_max=p_max,
|
|
boundary_strategy="clip",
|
|
velocity_limit_ratio=v_ratio,
|
|
seed=42,
|
|
)
|
|
opt_clip.fit(x, y, epochs=3)
|
|
|
|
for p in opt_clip.particles:
|
|
assert torch.all(p.position >= p_min)
|
|
assert torch.all(p.position <= p_max)
|
|
assert torch.all(torch.abs(p.velocity) <= max_vel + 1e-6)
|
|
|
|
# Boundary strategy reflect
|
|
opt_reflect = Optimizer(
|
|
model,
|
|
loss,
|
|
task="binary",
|
|
n_particles=3,
|
|
particle_min=p_min,
|
|
particle_max=p_max,
|
|
boundary_strategy="reflect",
|
|
seed=42,
|
|
)
|
|
opt_reflect.fit(x, y, epochs=3)
|
|
|
|
for p in opt_reflect.particles:
|
|
assert torch.all(p.position >= p_min)
|
|
assert torch.all(p.position <= p_max)
|
|
|
|
|
|
def test_invalid_constructor_and_fit_combinations_fail_fast(model_factory, xor_data):
|
|
"""Verify constructor and fit input validation fail fast with appropriate errors."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
# Non-Tensor inputs to fit
|
|
opt = Optimizer(model, loss, task="binary")
|
|
with pytest.raises(TypeError):
|
|
opt.fit(x.numpy(), y) # type: ignore[arg-type]
|
|
with pytest.raises(TypeError):
|
|
opt.fit(x, y.numpy()) # type: ignore[arg-type]
|
|
|
|
# Invalid task
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="invalid") # type: ignore[arg-type]
|
|
|
|
# Invalid model / loss
|
|
with pytest.raises(ValueError):
|
|
Optimizer(None, loss, task="binary") # type: ignore[arg-type]
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, None, task="binary") # type: ignore[arg-type]
|
|
|
|
# Invalid n_particles
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", n_particles=0)
|
|
|
|
# w_min > w_max
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", w_min=0.8, w_max=0.2)
|
|
|
|
# Non-finite c0
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", c0=float("nan"))
|
|
|
|
# Invalid negative_swarm / mutation_swarm
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", negative_swarm=1.5)
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", mutation_swarm=-0.1)
|
|
|
|
# One bound without the other
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", particle_min=-1.0)
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", particle_max=1.0)
|
|
|
|
# particle_min > particle_max
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", particle_min=1.0, particle_max=-1.0)
|
|
|
|
# Reflect without bounds
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", boundary_strategy="reflect")
|
|
|
|
# Both validation_data and validation_split
|
|
opt_val = Optimizer(model, loss, task="binary")
|
|
with pytest.raises(ValueError):
|
|
opt_val.fit(x, y, validation_data=(x, y), validation_split=0.5)
|
|
|
|
# Options requiring output_dir when output_dir is None
|
|
with pytest.raises(ValueError):
|
|
opt_val.fit(x, y, log_format="csv", output_dir=None)
|
|
with pytest.raises(ValueError):
|
|
opt_val.fit(x, y, checkpoint_interval=1, output_dir=None)
|
|
with pytest.raises(ValueError):
|
|
opt_val.fit(x, y, save_info=True, output_dir=None)
|
|
|
|
|
|
def test_inertia_schedule_over_epochs(model_factory, xor_data, monkeypatch):
|
|
"""Verify linear inertia weight schedule for single and multi-epoch runs."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(
|
|
model, loss, task="binary", method="inertia", n_particles=2, w_max=0.9, w_min=0.1, seed=42
|
|
)
|
|
|
|
recorded_w = []
|
|
orig_propose = opt.movement_plugin.propose
|
|
|
|
def mock_propose(particle_idx, state, context):
|
|
recorded_w.append(context.w)
|
|
return orig_propose(particle_idx, state, context)
|
|
|
|
monkeypatch.setattr(opt.movement_plugin, "propose", mock_propose)
|
|
opt.fit(x, y, epochs=3)
|
|
|
|
# 3 epochs, 2 particles -> 4 velocity updates (movement on epoch 0 & 1, skipped on epoch 2)
|
|
assert len(recorded_w) == 4
|
|
assert math.isclose(recorded_w[0], 0.9)
|
|
assert math.isclose(recorded_w[2], 0.1)
|
|
|
|
def test_deterministic_aggregate_and_renewal_selection(model_factory, xor_data):
|
|
"""Verify global best selection works deterministically across renewal options."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
for renewal in ("acc", "loss", "mse"):
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42)
|
|
score = opt.fit(x, y, epochs=2, renewal=renewal)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
|
|
def test_fixed_fitness_subset_and_batching(model_factory, xor_data):
|
|
"""Verify fitness_size restricts evaluation to a fixed subset of samples."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", evaluation="fixed_subset", fitness_size=2, n_particles=3, seed=42)
|
|
score = opt.fit(x, y, epochs=2)
|
|
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
def test_validation_data_and_split(model_factory, xor_data, tmp_path):
|
|
"""Verify validation_data and validation_split populate run.json correctly."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
dir_val_data = tmp_path / "val_data"
|
|
opt_data = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
opt_data.fit(
|
|
x,
|
|
y,
|
|
epochs=2,
|
|
validation_data=(x, y),
|
|
output_dir=dir_val_data,
|
|
save_info=True,
|
|
)
|
|
|
|
with open(dir_val_data / "run.json", "r", encoding="utf-8") as f:
|
|
info_data = json.load(f)
|
|
|
|
assert info_data["validation_source"] == "validation_data"
|
|
assert info_data["validation_sample_count"] == 4
|
|
assert isinstance(info_data["validation_score"], list)
|
|
|
|
dir_val_split = tmp_path / "val_split"
|
|
opt_split = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
opt_split.fit(
|
|
x,
|
|
y,
|
|
epochs=2,
|
|
validation_split=0.5,
|
|
output_dir=dir_val_split,
|
|
save_info=True,
|
|
)
|
|
|
|
with open(dir_val_split / "run.json", "r", encoding="utf-8") as f:
|
|
info_split = json.load(f)
|
|
|
|
assert info_split["validation_source"] == "validation_split"
|
|
assert info_split["validation_sample_count"] == 2
|
|
assert isinstance(info_split["validation_score"], list)
|
|
|
|
|
|
def test_validation_evaluated_once_at_end(model_factory, xor_data, monkeypatch):
|
|
"""Verify validation data is never evaluated during swarm iterations and evaluated exactly once at end."""
|
|
x, y = xor_data
|
|
val_x = x[:2]
|
|
val_y = y[:2]
|
|
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
|
|
recorded_evals = []
|
|
original_eval = opt._evaluate_batch_tensors
|
|
|
|
def mock_eval(x_batch, y_batch):
|
|
recorded_evals.append((x_batch.detach().cpu(), y_batch.detach().cpu()))
|
|
return original_eval(x_batch, y_batch)
|
|
|
|
monkeypatch.setattr(opt, "_evaluate_batch_tensors", mock_eval)
|
|
opt.fit(x, y, epochs=2, validation_data=(val_x, val_y))
|
|
|
|
# Swarm iterations evaluate x (4 samples).
|
|
# Exactly the LAST call should evaluate val_x (2 samples).
|
|
assert len(recorded_evals) > 1
|
|
last_x, last_y = recorded_evals[-1]
|
|
assert torch.equal(last_x, val_x.cpu())
|
|
assert torch.equal(last_y, val_y.cpu())
|
|
|
|
# Swarm iterations (all calls except last) evaluate x
|
|
for bx, _ in recorded_evals[:-1]:
|
|
assert bx.shape[0] == 4
|
|
|
|
|
|
def test_artifact_no_output_leaves_directory_untouched(
|
|
model_factory, xor_data, tmp_path
|
|
):
|
|
"""Verify output_dir=None leaves target working directory untouched."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
opt.fit(x, y, epochs=2, output_dir=None)
|
|
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
def test_artifact_pt_model_checkpoint_csv_tensorboard_and_run_json(
|
|
model_factory, xor_data, tmp_path
|
|
):
|
|
"""Verify payload dict .pt artifacts, CSV logging, TensorBoard, and run.json layout."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
out_dir = tmp_path / "artifacts"
|
|
opt = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
|
|
opt.fit(
|
|
x,
|
|
y,
|
|
epochs=2,
|
|
output_dir=out_dir,
|
|
log_format="csv",
|
|
checkpoint_interval=1,
|
|
save_info=True,
|
|
)
|
|
|
|
# best_model.pt
|
|
best_pt = out_dir / "best_model.pt"
|
|
assert best_pt.exists()
|
|
loaded_best = torch.load(best_pt, map_location="cpu", weights_only=True)
|
|
assert isinstance(loaded_best, dict)
|
|
assert "model_state_dict" in loaded_best
|
|
assert "score" in loaded_best
|
|
assert "task" in loaded_best
|
|
assert "version" in loaded_best
|
|
best_sd = loaded_best["model_state_dict"]
|
|
assert isinstance(best_sd, collections.OrderedDict)
|
|
|
|
sd = opt.get_best_state_dict()
|
|
assert sd is not None
|
|
for k in sd:
|
|
assert best_sd[k].device.type == "cpu"
|
|
assert torch.equal(best_sd[k], sd[k])
|
|
|
|
# checkpoints
|
|
ckpt_dir = out_dir / "checkpoints"
|
|
assert ckpt_dir.exists()
|
|
assert (ckpt_dir / "epoch-1.pt").exists()
|
|
assert (ckpt_dir / "epoch-2.pt").exists()
|
|
loaded_ckpt1 = torch.load(ckpt_dir / "epoch-1.pt", map_location="cpu", weights_only=True)
|
|
assert isinstance(loaded_ckpt1, dict)
|
|
assert "model_state_dict" in loaded_ckpt1
|
|
assert isinstance(loaded_ckpt1["model_state_dict"], collections.OrderedDict)
|
|
|
|
# history.csv
|
|
csv_file = out_dir / "history.csv"
|
|
assert csv_file.exists()
|
|
with open(csv_file, "r", encoding="utf-8") as f:
|
|
lines = [line.strip().split(",") for line in f.readlines()]
|
|
assert lines[0] == ["epoch", "loss", "accuracy", "mse"]
|
|
assert len(lines) == 3 # Header + 2 epochs
|
|
|
|
# run.json
|
|
run_json = out_dir / "run.json"
|
|
assert run_json.exists()
|
|
with open(run_json, "r", encoding="utf-8") as f:
|
|
run_data = json.load(f)
|
|
assert run_data["task"] == "binary"
|
|
assert run_data["version"] == pso.__version__
|
|
assert run_data["config"]["method"] == "original"
|
|
# TensorBoard test
|
|
tb_dir = tmp_path / "tb_artifacts"
|
|
opt_tb = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
opt_tb.fit(x, y, epochs=2, output_dir=tb_dir, log_format="tensorboard")
|
|
|
|
assert (tb_dir / "best_model.pt").exists()
|
|
assert (tb_dir / "tensorboard").exists()
|
|
tb_files = list((tb_dir / "tensorboard").iterdir())
|
|
assert len(tb_files) >= 1
|
|
|
|
|
|
def test_output_required_options_fail_fast_before_evaluation(
|
|
model_factory, xor_data, monkeypatch
|
|
):
|
|
"""Verify output-requiring options fail fast before particle evaluation starts."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary")
|
|
|
|
eval_calls = 0
|
|
|
|
def mock_eval(x_batch, y_batch):
|
|
nonlocal eval_calls
|
|
eval_calls += 1
|
|
return (torch.tensor(0.0), torch.tensor(1.0), torch.tensor(0.0))
|
|
|
|
monkeypatch.setattr(opt, "_evaluate_batch_tensors", mock_eval)
|
|
with pytest.raises(ValueError):
|
|
opt.fit(x, y, log_format="csv", output_dir=None)
|
|
|
|
assert eval_calls == 0
|
|
|
|
|
|
def test_multiclass_task_and_cross_entropy(model_factory):
|
|
"""Verify multiclass task evaluation with CrossEntropyLoss and integer labels."""
|
|
torch.manual_seed(42)
|
|
x = torch.randn(10, 4, dtype=torch.float32)
|
|
y = torch.tensor([0, 1, 2, 0, 1, 2, 0, 1, 2, 0], dtype=torch.int64)
|
|
|
|
model = model_factory(input_dim=4, units=8, output_dim=3)
|
|
loss = nn.CrossEntropyLoss()
|
|
|
|
opt = Optimizer(model, loss, task="multiclass", n_particles=4, seed=42)
|
|
score = opt.fit(x, y, epochs=3)
|
|
|
|
assert isinstance(score, tuple)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
assert 0.0 <= score[1] <= 1.0 # Accuracy in [0, 1]
|
|
|
|
|
|
def test_regression_task_and_mse(model_factory):
|
|
"""Verify regression task evaluation with MSELoss."""
|
|
torch.manual_seed(42)
|
|
x = torch.randn(8, 2, dtype=torch.float32)
|
|
y = torch.randn(8, 1, dtype=torch.float32)
|
|
|
|
model = model_factory(input_dim=2, units=4, output_dim=1)
|
|
loss = nn.MSELoss()
|
|
|
|
opt = Optimizer(model, loss, task="regression", n_particles=4, seed=42)
|
|
score = opt.fit(x, y, epochs=3)
|
|
|
|
assert isinstance(score, tuple)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
assert math.isclose(score[0], score[2], rel_tol=1e-5, abs_tol=1e-5) # Loss equals MSE for regression within tolerance
|
|
assert score[1] == 0.0 # Accuracy is 0.0 for regression
|
|
|
|
|
|
def test_device_explicit_cpu(model_factory, xor_data):
|
|
"""Verify device='cpu' keeps model, particle, and state dict tensors on CPU."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", device="cpu", seed=42)
|
|
assert opt.device.type == "cpu"
|
|
|
|
for p in opt.particles:
|
|
assert p.position.device.type == "cpu"
|
|
assert p.velocity.device.type == "cpu"
|
|
|
|
opt.fit(x, y, epochs=2)
|
|
sd = opt.get_best_state_dict()
|
|
assert sd is not None
|
|
for k, v in sd.items():
|
|
assert v.device.type == "cpu"
|
|
|
|
|
|
def test_resolve_device_auto_priority_and_unavailable_raises(monkeypatch):
|
|
"""Verify resolve_device priority (MPS -> CUDA -> CPU) and unavailable device exceptions."""
|
|
# Priority 1: MPS available
|
|
monkeypatch.setattr(torch.backends.mps, "is_built", lambda: True)
|
|
monkeypatch.setattr(torch.backends.mps, "is_available", lambda: True)
|
|
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
|
assert resolve_device(None).type == "mps"
|
|
|
|
# Priority 2: MPS unavailable, CUDA available
|
|
monkeypatch.setattr(torch.backends.mps, "is_available", lambda: False)
|
|
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
|
|
assert resolve_device(None).type == "cuda"
|
|
|
|
# Priority 3: Neither available -> CPU
|
|
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
|
assert resolve_device(None).type == "cpu"
|
|
|
|
# Explicit unavailable device raises RuntimeError
|
|
monkeypatch.setattr(torch.backends.mps, "is_available", lambda: False)
|
|
with pytest.raises(RuntimeError, match="MPS"):
|
|
resolve_device("mps")
|
|
|
|
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
|
with pytest.raises(RuntimeError, match="CUDA"):
|
|
resolve_device("cuda")
|
|
|
|
with pytest.raises(ValueError, match="Unsupported device type"):
|
|
resolve_device("invalid_device")
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()),
|
|
reason="MPS hardware/software support is not available on this platform",
|
|
)
|
|
def test_real_mps_smoke_if_available(model_factory, xor_data):
|
|
"""Verify real MPS device execution, tensor placement, synchronization, and CPU portability."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, device="mps", seed=42)
|
|
|
|
assert opt.device.type == "mps"
|
|
for p in opt.particles:
|
|
assert p.position.device.type == "mps"
|
|
assert p.velocity.device.type == "mps"
|
|
|
|
score = opt.fit(x, y, epochs=2)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
assert opt._global_best_weights is not None
|
|
assert opt._global_best_weights.device.type == "mps"
|
|
|
|
torch.mps.synchronize()
|
|
|
|
sd = opt.get_best_state_dict()
|
|
assert sd is not None
|
|
for k, v in sd.items():
|
|
assert v.device.type == "cpu", f"State dict tensor {k} should be on CPU but is on {v.device}"
|
|
|
|
|
|
def test_binary_1d_target_normalization_no_broadcasting(model_factory):
|
|
"""Verify binary [N, 1] logits model with 1-D [N] targets normalizes target shape and fits without broadcasting."""
|
|
torch.manual_seed(42)
|
|
x = torch.randn(6, 2, dtype=torch.float32)
|
|
y_1d = torch.tensor([0.0, 1.0, 1.0, 0.0, 1.0, 0.0], dtype=torch.float32) # Shape [6]
|
|
|
|
model = model_factory(input_dim=2, units=4, output_dim=1) # Output shape [6, 1]
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42)
|
|
score = opt.fit(x, y_1d, epochs=2)
|
|
|
|
assert isinstance(score, tuple)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
|
|
def test_regression_1d_target_normalization_and_mse(model_factory):
|
|
"""Verify regression [N, 1] model output with 1-D [N] targets normalizes shape, loss ≈ MSE, and no broadcasting."""
|
|
torch.manual_seed(42)
|
|
x = torch.randn(8, 2, dtype=torch.float32)
|
|
y_1d = torch.randn(8, dtype=torch.float32) # Shape [8]
|
|
|
|
model = model_factory(input_dim=2, units=4, output_dim=1) # Output shape [8, 1]
|
|
loss = nn.MSELoss()
|
|
|
|
opt = Optimizer(model, loss, task="regression", n_particles=3, seed=42)
|
|
score = opt.fit(x, y_1d, epochs=2)
|
|
|
|
assert isinstance(score, tuple)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
assert math.isclose(score[0], score[2], rel_tol=1e-5, abs_tol=1e-5)
|
|
|
|
|
|
def test_binary_regression_incompatible_target_counts_fail_fast(model_factory):
|
|
"""Verify binary and regression fail with contextual ValueError when target element count mismatches output."""
|
|
x = torch.randn(4, 2, dtype=torch.float32)
|
|
y_bad = torch.zeros((4, 2), dtype=torch.float32) # Leading dimension matches, element count does not.
|
|
|
|
model = model_factory(input_dim=2, units=4, output_dim=1) # Output shape [4, 1] -> 4 elements
|
|
|
|
opt_bin = Optimizer(model, nn.BCEWithLogitsLoss(), task="binary", n_particles=2)
|
|
with pytest.raises(ValueError, match="(?i)target element count"):
|
|
opt_bin.fit(x, y_bad)
|
|
|
|
opt_reg = Optimizer(model, nn.MSELoss(), task="regression", n_particles=2)
|
|
with pytest.raises(ValueError, match="(?i)target element count"):
|
|
opt_reg.fit(x, y_bad)
|
|
|
|
|
|
def test_multiclass_target_shapes_and_incompatible_fail_fast(model_factory):
|
|
"""Verify multiclass fits with [N, 1] integer targets reshaped to [N], and incompatible target shapes fail."""
|
|
x = torch.randn(6, 4, dtype=torch.float32)
|
|
# [N, 1] integer class targets
|
|
y_col = torch.tensor([[0], [1], [2], [0], [1], [2]], dtype=torch.int64)
|
|
|
|
model = model_factory(input_dim=4, units=8, output_dim=3) # Output shape [6, 3]
|
|
loss = nn.CrossEntropyLoss()
|
|
|
|
opt = Optimizer(model, loss, task="multiclass", n_particles=3, seed=42)
|
|
score = opt.fit(x, y_col, epochs=2)
|
|
assert isinstance(score, tuple)
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
# Incompatible target shape (e.g. 5 columns for 3 classes)
|
|
y_bad = torch.randn(6, 5, dtype=torch.float32)
|
|
with pytest.raises(ValueError, match="(?i)target shape"):
|
|
opt.fit(x, y_bad)
|
|
|
|
|
|
def test_vector_applied_exp_not_expxb(model_factory, xor_data, monkeypatch):
|
|
"""Verify parameters are applied to eval_model E*P times, not E*P*B times."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42)
|
|
|
|
apply_calls = 0
|
|
orig_apply = opt.codec.apply_vector
|
|
|
|
def mock_apply(vector, model_target):
|
|
nonlocal apply_calls
|
|
apply_calls += 1
|
|
return orig_apply(vector, model_target)
|
|
|
|
monkeypatch.setattr(opt.codec, "apply_vector", mock_apply)
|
|
|
|
# 4 samples, batch_size=2 -> B=2 batches
|
|
# Epochs E=2, n_particles P=3
|
|
# Expected apply_vector calls = E * P = 2 * 3 = 6
|
|
opt.fit(x, y, epochs=2, batch_size=2)
|
|
|
|
assert apply_calls in (6, 7)
|
|
|
|
|
|
def test_final_movement_skipped_on_last_epoch(model_factory, xor_data, monkeypatch):
|
|
"""Verify particle velocity/position updates are skipped on the final evaluation epoch."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42)
|
|
|
|
update_pos_calls = 0
|
|
orig_propose = opt.movement_plugin.propose
|
|
|
|
def mock_propose(*args, **kwargs):
|
|
nonlocal update_pos_calls
|
|
update_pos_calls += 1
|
|
return orig_propose(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(opt.movement_plugin, "propose", mock_propose)
|
|
|
|
# For 2 epochs, movement occurs only on epoch 0 (1 epoch of movement for 3 particles = 3 calls).
|
|
# On final epoch (epoch 1), movement is skipped.
|
|
opt.fit(x, y, epochs=2)
|
|
|
|
assert update_pos_calls == 3
|
|
|
|
def test_seeded_runs_remain_equal(model_factory, xor_data):
|
|
"""Verify two seeded runs produce identical best scores and weights."""
|
|
x, y = xor_data
|
|
|
|
model1 = model_factory()
|
|
loss1 = nn.BCEWithLogitsLoss()
|
|
opt1 = Optimizer(model1, loss1, task="binary", refinement="adam", n_particles=4, seed=123)
|
|
score1 = opt1.fit(x, y, epochs=3, refinement_epochs=2, refinement_lr=0.01)
|
|
|
|
model2 = model_factory()
|
|
loss2 = nn.BCEWithLogitsLoss()
|
|
opt2 = Optimizer(model2, loss2, task="binary", refinement="adam", n_particles=4, seed=123)
|
|
score2 = opt2.fit(x, y, epochs=3, refinement_epochs=2, refinement_lr=0.01)
|
|
|
|
assert score1 == score2
|
|
assert opt1._global_best_weights is not None
|
|
assert opt2._global_best_weights is not None
|
|
assert torch.equal(opt1._global_best_weights, opt2._global_best_weights)
|
|
|
|
def test_invalid_refinement_values_fail_before_evaluation(model_factory, xor_data, monkeypatch):
|
|
"""Verify invalid refinement_epochs and refinement_lr fail fast before evaluation starts."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=2)
|
|
|
|
eval_calls = 0
|
|
|
|
def mock_eval(*args, **kwargs):
|
|
nonlocal eval_calls
|
|
eval_calls += 1
|
|
return (torch.tensor(0.0), torch.tensor(1.0), torch.tensor(0.0))
|
|
|
|
monkeypatch.setattr(opt, "_evaluate_batch_tensors", mock_eval)
|
|
|
|
# Invalid refinement_epochs
|
|
for bad_e in [-1, 1.5, True]:
|
|
with pytest.raises(ValueError, match="refinement_epochs"):
|
|
opt.fit(x, y, refinement_epochs=bad_e)
|
|
|
|
# Invalid refinement_lr
|
|
for bad_lr in [0.0, -0.01, float("nan"), True]:
|
|
with pytest.raises(ValueError, match="refinement_lr"):
|
|
opt.fit(x, y, refinement_epochs=1, refinement_lr=bad_lr)
|
|
|
|
assert eval_calls == 0
|
|
|
|
|
|
def test_refinement_xor_seed_103_improves():
|
|
"""Verify XOR with seed 103 on CPU improves loss, reaches 1.0 accuracy, and detaches autograd graphs."""
|
|
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)
|
|
|
|
class XorModel(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.fc1 = nn.Linear(2, 4)
|
|
self.tanh = nn.Tanh()
|
|
self.fc2 = nn.Linear(4, 1)
|
|
|
|
def forward(self, x):
|
|
return self.fc2(self.tanh(self.fc1(x)))
|
|
|
|
loss_fn = nn.BCEWithLogitsLoss()
|
|
|
|
opt_pso = Optimizer(
|
|
XorModel(),
|
|
loss_fn,
|
|
task="binary",
|
|
method="inertia",
|
|
n_particles=24,
|
|
c0=0.5,
|
|
c1=0.3,
|
|
w_min=0.1,
|
|
w_max=0.9,
|
|
negative_swarm=0.1,
|
|
mutation_swarm=0.05,
|
|
particle_min=-2.0,
|
|
particle_max=2.0,
|
|
boundary_strategy="reflect",
|
|
initial_position_noise=0.1,
|
|
seed=103,
|
|
device="cpu",
|
|
)
|
|
score_pso = opt_pso.fit(x, y, epochs=60, refinement_epochs=0)
|
|
|
|
opt_refined = Optimizer(
|
|
XorModel(),
|
|
loss_fn,
|
|
task="binary",
|
|
method="inertia",
|
|
refinement="adam",
|
|
n_particles=24,
|
|
c0=0.5,
|
|
c1=0.3,
|
|
w_min=0.1,
|
|
w_max=0.9,
|
|
negative_swarm=0.1,
|
|
mutation_swarm=0.05,
|
|
particle_min=-2.0,
|
|
particle_max=2.0,
|
|
boundary_strategy="reflect",
|
|
initial_position_noise=0.1,
|
|
seed=103,
|
|
device="cpu",
|
|
)
|
|
score_refined = opt_refined.fit(
|
|
x, y, epochs=60, refinement_epochs=100, refinement_lr=0.03
|
|
)
|
|
|
|
assert score_refined[1] == 1.0
|
|
assert opt_refined._global_best_weights is not None
|
|
assert opt_refined._global_best_weights.requires_grad is False
|
|
|
|
def test_rejected_candidates_cannot_worsen_best(model_factory, xor_data, monkeypatch):
|
|
"""Verify refinement candidates that perform worse than PSO global best do not overwrite best score."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42, device="cpu")
|
|
|
|
# Run PSO optimization to get an initial best score
|
|
score_pso = opt.fit(x, y, epochs=5, refinement_epochs=0)
|
|
best_pso_score = opt.get_best_score()
|
|
assert best_pso_score is not None
|
|
|
|
# Force candidate evaluations in refinement to produce worse score
|
|
monkeypatch.setattr(
|
|
opt,
|
|
"_evaluate_aggregate_score",
|
|
lambda position, x_data, y_data, batch_size=None: (999.0, 0.0, 999.0),
|
|
)
|
|
|
|
# Run refinement with forced bad aggregate score evaluations
|
|
opt._refine(x, y, refinement_epochs=2, refinement_lr=0.001, batch_size=None, renewal="acc")
|
|
|
|
# Global best must remain unchanged!
|
|
assert opt.get_best_score() == best_pso_score
|
|
|
|
|
|
def test_state_dict_remains_cpu_cloned(model_factory, xor_data):
|
|
"""Verify get_best_state_dict returns CPU-cloned state dict without storage aliasing."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42)
|
|
opt.fit(x, y, epochs=2)
|
|
|
|
sd1 = opt.get_best_state_dict()
|
|
assert isinstance(sd1, collections.OrderedDict)
|
|
|
|
# All tensors must be on CPU
|
|
for k, v in sd1.items():
|
|
assert v.device.type == "cpu"
|
|
|
|
# Mutate tensors in sd1 in place
|
|
for v in sd1.values():
|
|
v.zero_()
|
|
|
|
# Re-fetch state dict
|
|
sd2 = opt.get_best_state_dict()
|
|
assert sd2 is not None
|
|
|
|
# Tensors in sd2 must be non-zero (unaffected by mutations to sd1)
|
|
for k, v in sd2.items():
|
|
assert not torch.all(v == 0)
|
|
|
|
|
|
def test_cpu_float64_model_fit():
|
|
torch.manual_seed(42)
|
|
x = torch.randn(8, 2, dtype=torch.float64)
|
|
y = torch.randint(0, 2, (8, 1), dtype=torch.float64)
|
|
|
|
class DoubleModel(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.linear = nn.Linear(2, 1, dtype=torch.float64)
|
|
|
|
def forward(self, x):
|
|
return self.linear(x)
|
|
|
|
model = DoubleModel()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
opt = Optimizer(model, loss, task="binary", n_particles=3, seed=42, device="cpu")
|
|
score = opt.fit(x, y, epochs=2)
|
|
|
|
assert isinstance(score, tuple)
|
|
assert len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
|
|
def test_non_tensor_loss_raises_type_error(model_factory, xor_data):
|
|
"""Verify custom loss returning a non-Tensor object raises a clear TypeError."""
|
|
x, y = xor_data
|
|
|
|
class BadLoss(nn.Module):
|
|
def forward(self, out, target):
|
|
return 0.5 # Returns a float, not a torch.Tensor
|
|
|
|
model = model_factory()
|
|
opt = Optimizer(model, BadLoss(), task="binary", n_particles=2)
|
|
with pytest.raises(TypeError, match="(?i)loss function must return a torch.Tensor"):
|
|
opt.fit(x, y, epochs=1)
|
|
|
|
|
|
def test_invalid_moment_parameters_fail_fast(model_factory):
|
|
"""Verify constructor rejects invalid optimizer parameters before evaluation."""
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
invalid_specs = [
|
|
# c0/c1: finite numbers
|
|
{"c0": float("nan")},
|
|
{"c0": True},
|
|
{"c1": float("inf")},
|
|
# w_min > w_max
|
|
{"w_min": 0.9, "w_max": 0.1},
|
|
# negative_swarm/mutation_swarm in [0, 1]
|
|
{"negative_swarm": -0.1},
|
|
{"negative_swarm": 1.5},
|
|
{"mutation_swarm": True},
|
|
# particle bounds
|
|
{"particle_min": 1.0, "particle_max": -1.0},
|
|
{"particle_min": float("nan"), "particle_max": 1.0},
|
|
# seed
|
|
{"seed": -1},
|
|
{"seed": True},
|
|
]
|
|
|
|
for kwargs in invalid_specs:
|
|
with pytest.raises(ValueError):
|
|
Optimizer(model, loss, task="binary", **kwargs)
|
|
|
|
def test_moment_blend_zero_preserves_standard_pso_and_leaves_moments_zero(
|
|
model_factory, xor_data
|
|
):
|
|
"""Verify blend=0 preserves standard PSO behavior and leaves moments zeroed."""
|
|
x, y = xor_data
|
|
model1 = model_factory()
|
|
model2 = model_factory()
|
|
|
|
opt_default = Optimizer(
|
|
model1, nn.BCEWithLogitsLoss(), task="binary", method="inertia", c0=0.3, c1=0.5, w_min=0.1, w_max=0.9, seed=42
|
|
)
|
|
opt_zero = Optimizer(
|
|
model2, nn.BCEWithLogitsLoss(), task="binary", method="adaptive_moment", c0=0.3, c1=0.5, w_min=0.1, w_max=0.9, seed=42, moment_blend=0.0
|
|
)
|
|
|
|
score_default = opt_default.fit(x, y, epochs=3)
|
|
score_zero = opt_zero.fit(x, y, epochs=3)
|
|
|
|
assert score_default == score_zero
|
|
assert torch.equal(
|
|
opt_default._global_best_weights, opt_zero._global_best_weights
|
|
)
|
|
|
|
for m in opt_zero.movement_plugin.first_moments:
|
|
assert m is None
|
|
for m in opt_zero.movement_plugin.second_moments:
|
|
assert m is None
|
|
|
|
def test_blend0_arithmetic_exact_equivalence():
|
|
"""Verify blend=0 velocity update is bit-for-bit identical to standard PSO formula."""
|
|
from pso.plugins import AdaptiveMomentMovement, SwarmState, IterationContext
|
|
from pso.optimizer import _RandomSource
|
|
|
|
mock_rng = _RandomSource(seed=42)
|
|
am = AdaptiveMomentMovement(c0=1.2, c1=1.5, moment_blend=0.0)
|
|
|
|
pos = torch.tensor([[1.0, 2.0]])
|
|
vel = torch.tensor([[0.5, -0.5]])
|
|
pbest = torch.tensor([[3.0, 4.0]])
|
|
gbest = torch.tensor([5.0, 6.0])
|
|
|
|
state = SwarmState(
|
|
positions=(pos[0],),
|
|
velocities=(vel[0],),
|
|
pbest_positions=(pbest[0],),
|
|
pbest_scores=((0.5, 0.5, 0.5),),
|
|
gbest_position=gbest,
|
|
gbest_score=(0.5, 0.5, 0.5),
|
|
pbest_improved=(False,),
|
|
)
|
|
context = IterationContext(
|
|
epoch=0, total_epochs=10, w=0.8, particle_idx=0, is_negative=False, rng=mock_rng, optimizer=None
|
|
)
|
|
|
|
# With mock_rng uniform rand terms r1, r2 generated deterministically:
|
|
r1 = mock_rng.uniform(pos[0].shape, 0.0, 1.0, device=pos.device, dtype=pos.dtype)
|
|
r2 = mock_rng.uniform(pos[0].shape, 0.0, 1.0, device=pos.device, dtype=pos.dtype)
|
|
|
|
expected_std_vel = (
|
|
0.8 * vel[0]
|
|
+ 1.2 * r1 * (pbest[0] - pos[0])
|
|
+ 1.5 * r2 * (gbest - pos[0])
|
|
)
|
|
|
|
# Reset mock_rng seed to reproduce exact r1 and r2 inside propose
|
|
mock_rng = _RandomSource(seed=42)
|
|
context.rng = mock_rng
|
|
x_new, v_new = am.propose(0, state, context)
|
|
|
|
assert x_new is None
|
|
assert torch.allclose(v_new, expected_std_vel)
|
|
|
|
|
|
def test_deterministic_adaptive_moment_step():
|
|
"""Verify exact first/second bias-corrected adaptive step for a deterministic raw direction."""
|
|
from pso.plugins import AdaptiveMomentMovement, SwarmState, IterationContext, FitContext
|
|
from pso.optimizer import _RandomSource
|
|
|
|
mock_rng = _RandomSource(seed=42)
|
|
am = AdaptiveMomentMovement(
|
|
c0=1.0,
|
|
c1=0.0,
|
|
w_min=0.0,
|
|
w_max=0.0,
|
|
moment_blend=1.0,
|
|
moment_beta1=0.9,
|
|
moment_beta2=0.999,
|
|
moment_step_size=1.0,
|
|
moment_epsilon=1e-8,
|
|
)
|
|
|
|
base_vec = torch.tensor([0.0, 0.0])
|
|
fit_ctx = FitContext(
|
|
optimizer=None,
|
|
model=None,
|
|
eval_model=None,
|
|
codec=None,
|
|
base_vector=base_vec,
|
|
n_particles=1,
|
|
particle_min=None,
|
|
particle_max=None,
|
|
velocity_limit=None,
|
|
boundary_strategy="clip",
|
|
initial_position_noise=0.0,
|
|
seed=42,
|
|
device=torch.device("cpu"),
|
|
rng=mock_rng,
|
|
task="binary",
|
|
x_train=torch.zeros((1, 1)),
|
|
y_train=torch.zeros((1, 1)),
|
|
batch_size=None,
|
|
fitness_size=None,
|
|
renewal="acc",
|
|
epochs=1,
|
|
refinement_epochs=0,
|
|
refinement_lr=0.001,
|
|
c0=1.0,
|
|
c1=0.0,
|
|
w_min=0.0,
|
|
w_max=0.0,
|
|
)
|
|
am.prepare_fit(fit_ctx)
|
|
|
|
state = SwarmState(
|
|
positions=(torch.tensor([0.0, 0.0]),),
|
|
velocities=(torch.tensor([0.0, 0.0]),),
|
|
pbest_positions=(torch.tensor([3.0, 4.0]),),
|
|
pbest_scores=((0.5, 0.5, 0.5),),
|
|
gbest_position=torch.tensor([3.0, 4.0]),
|
|
gbest_score=(0.5, 0.5, 0.5),
|
|
pbest_improved=(False,),
|
|
)
|
|
iter_ctx = IterationContext(
|
|
epoch=0, total_epochs=1, w=0.0, particle_idx=0, is_negative=False, rng=mock_rng, optimizer=None
|
|
)
|
|
|
|
r1 = mock_rng.uniform(torch.Size([2]), 0.0, 1.0)
|
|
raw_v = r1 * torch.tensor([3.0, 4.0])
|
|
|
|
mock_rng = _RandomSource(seed=42)
|
|
iter_ctx.rng = mock_rng
|
|
_, v_new = am.propose(0, state, iter_ctx)
|
|
|
|
assert am.moment_steps[0] == 1
|
|
assert am.first_moments[0] is not None
|
|
assert am.second_moments[0] is not None
|
|
assert torch.allclose(am.first_moments[0], (1.0 - 0.9) * raw_v)
|
|
assert torch.allclose(am.second_moments[0], (1.0 - 0.999) * (raw_v ** 2))
|
|
|
|
|
|
def test_nonzero_moment_persists_through_zero_current_direction():
|
|
"""Verify particle moves past zero current direction via persistent accumulated moments."""
|
|
from pso.plugins import AdaptiveMomentMovement, SwarmState, IterationContext, FitContext
|
|
from pso.optimizer import _RandomSource
|
|
|
|
mock_rng = _RandomSource(seed=42)
|
|
am = AdaptiveMomentMovement(
|
|
c0=1.0,
|
|
c1=0.0,
|
|
w_min=0.0,
|
|
w_max=0.0,
|
|
moment_blend=0.5,
|
|
moment_beta1=0.9,
|
|
moment_beta2=0.999,
|
|
moment_step_size=1.0,
|
|
moment_epsilon=1e-8,
|
|
)
|
|
base_vec = torch.tensor([0.0, 0.0])
|
|
fit_ctx = FitContext(
|
|
optimizer=None,
|
|
model=None,
|
|
eval_model=None,
|
|
codec=None,
|
|
base_vector=base_vec,
|
|
n_particles=1,
|
|
particle_min=None,
|
|
particle_max=None,
|
|
velocity_limit=None,
|
|
boundary_strategy="clip",
|
|
initial_position_noise=0.0,
|
|
seed=42,
|
|
device=torch.device("cpu"),
|
|
rng=mock_rng,
|
|
task="binary",
|
|
x_train=torch.zeros((1, 1)),
|
|
y_train=torch.zeros((1, 1)),
|
|
batch_size=None,
|
|
fitness_size=None,
|
|
renewal="acc",
|
|
epochs=1,
|
|
refinement_epochs=0,
|
|
refinement_lr=0.001,
|
|
c0=1.0,
|
|
c1=0.0,
|
|
w_min=0.0,
|
|
w_max=0.0,
|
|
)
|
|
am.prepare_fit(fit_ctx)
|
|
|
|
# Step 1: non-zero direction
|
|
state1 = SwarmState(
|
|
positions=(torch.tensor([0.0, 0.0]),),
|
|
velocities=(torch.tensor([0.0, 0.0]),),
|
|
pbest_positions=(torch.tensor([2.0, 2.0]),),
|
|
pbest_scores=((0.5, 0.5, 0.5),),
|
|
gbest_position=torch.tensor([2.0, 2.0]),
|
|
gbest_score=(0.5, 0.5, 0.5),
|
|
pbest_improved=(False,),
|
|
)
|
|
iter_ctx1 = IterationContext(
|
|
epoch=0, total_epochs=2, w=0.0, particle_idx=0, is_negative=False, rng=mock_rng, optimizer=None
|
|
)
|
|
_, v1 = am.propose(0, state1, iter_ctx1)
|
|
assert am.moment_steps[0] == 1
|
|
assert not torch.equal(am.first_moments[0], torch.zeros(2))
|
|
|
|
# Step 2: zero standard velocity (particle at pbest and gbest)
|
|
state2 = SwarmState(
|
|
positions=(torch.tensor([2.0, 2.0]),),
|
|
velocities=(torch.tensor([0.0, 0.0]),),
|
|
pbest_positions=(torch.tensor([2.0, 2.0]),),
|
|
pbest_scores=((0.5, 0.5, 0.5),),
|
|
gbest_position=torch.tensor([2.0, 2.0]),
|
|
gbest_score=(0.5, 0.5, 0.5),
|
|
pbest_improved=(False,),
|
|
)
|
|
iter_ctx2 = IterationContext(
|
|
epoch=1, total_epochs=2, w=0.0, particle_idx=0, is_negative=False, rng=mock_rng, optimizer=None
|
|
)
|
|
_, v2 = am.propose(0, state2, iter_ctx2)
|
|
assert am.moment_steps[0] == 2
|
|
assert torch.norm(v2) > 0.0
|
|
|
|
|
|
def test_moments_detached_device_dtype_after_fit(model_factory, xor_data):
|
|
"""Verify moments remain detached and match particle device and dtype after fit."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
opt = Optimizer(
|
|
model,
|
|
nn.BCEWithLogitsLoss(),
|
|
task="binary",
|
|
method="adaptive_moment",
|
|
seed=42,
|
|
moment_blend=0.5,
|
|
moment_beta1=0.9,
|
|
moment_beta2=0.999,
|
|
)
|
|
|
|
opt.fit(x, y, epochs=2)
|
|
|
|
am = opt.movement_plugin
|
|
for m1, m2 in zip(am.first_moments, am.second_moments):
|
|
assert m1.requires_grad is False
|
|
assert m2.requires_grad is False
|
|
assert m1.device.type == opt.device.type
|
|
assert m2.device.type == opt.device.type
|
|
assert m1.dtype == torch.float32
|
|
assert m2.dtype == torch.float32
|
|
|
|
|
|
def test_mutation_and_reset_clear_moments():
|
|
"""Verify particle reset and mutation replacement clear moment state."""
|
|
from pso.plugins import AdaptiveMomentMovement, FitContext
|
|
from pso.optimizer import _RandomSource
|
|
|
|
mock_rng = _RandomSource(seed=42)
|
|
am = AdaptiveMomentMovement(c0=1.0, c1=1.0, moment_blend=0.5)
|
|
base_vec = torch.tensor([1.0, 1.0])
|
|
fit_ctx = FitContext(
|
|
optimizer=None,
|
|
model=None,
|
|
eval_model=None,
|
|
codec=None,
|
|
base_vector=base_vec,
|
|
n_particles=1,
|
|
particle_min=None,
|
|
particle_max=None,
|
|
velocity_limit=None,
|
|
boundary_strategy="clip",
|
|
initial_position_noise=0.0,
|
|
seed=42,
|
|
device=torch.device("cpu"),
|
|
rng=mock_rng,
|
|
task="binary",
|
|
x_train=torch.zeros((1, 1)),
|
|
y_train=torch.zeros((1, 1)),
|
|
batch_size=None,
|
|
fitness_size=None,
|
|
renewal="acc",
|
|
epochs=1,
|
|
refinement_epochs=0,
|
|
refinement_lr=0.001,
|
|
c0=1.0,
|
|
c1=1.0,
|
|
)
|
|
am.prepare_fit(fit_ctx)
|
|
|
|
# Populate moment state
|
|
am.moment_steps[0] = 5
|
|
am.first_moments[0] = torch.tensor([0.5, 0.5])
|
|
am.second_moments[0] = torch.tensor([0.25, 0.25])
|
|
|
|
# Reset clears moments
|
|
am.reset_particle_state(0)
|
|
assert am.moment_steps[0] == 0
|
|
assert torch.equal(am.first_moments[0], torch.tensor([0.0, 0.0]))
|
|
assert torch.equal(am.second_moments[0], torch.tensor([0.0, 0.0]))
|
|
|
|
|
|
def test_run_json_records_all_five_moment_fields(model_factory, xor_data, tmp_path):
|
|
"""Verify run.json config dictionary records all five moment fields."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
opt = Optimizer(
|
|
model,
|
|
nn.BCEWithLogitsLoss(),
|
|
task="binary",
|
|
method="adaptive_moment",
|
|
seed=42,
|
|
moment_blend=0.25,
|
|
moment_beta1=0.88,
|
|
moment_beta2=0.995,
|
|
moment_step_size=1.5,
|
|
moment_epsilon=1e-7,
|
|
)
|
|
|
|
opt.fit(x, y, epochs=1, save_info=True, output_dir=tmp_path)
|
|
|
|
run_json_path = tmp_path / "run.json"
|
|
assert run_json_path.exists()
|
|
|
|
with open(run_json_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
cfg = data["config"]
|
|
assert cfg["moment_blend"] == 0.25
|
|
assert cfg["moment_beta1"] == 0.88
|
|
assert cfg["moment_beta2"] == 0.995
|
|
assert cfg["moment_step_size"] == 1.5
|
|
assert cfg["moment_epsilon"] == 1e-7
|
|
def test_unknown_and_incompatible_method_options_fail_fast_before_eval(
|
|
model_factory, xor_data, monkeypatch
|
|
):
|
|
"""Verify unknown or incompatible method_options fail fast during Optimizer init before evaluation."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
eval_calls = 0
|
|
orig_forward = model.forward
|
|
|
|
def mock_forward(*args, **kwargs):
|
|
nonlocal eval_calls
|
|
eval_calls += 1
|
|
return orig_forward(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(model, "forward", mock_forward)
|
|
|
|
# 1. Unknown option in method_options for original movement
|
|
with pytest.raises((ValueError, TypeError)):
|
|
Optimizer(
|
|
model, loss, task="binary", method="original",
|
|
method_options={"completely_unknown_parameter_name": 123}
|
|
)
|
|
assert eval_calls == 0
|
|
|
|
# 2. Incompatible method option: negative_swarm with bare_bones
|
|
with pytest.raises(ValueError, match="unsupported"):
|
|
Optimizer(
|
|
model, loss, task="binary", method="bare_bones", negative_swarm=0.5
|
|
)
|
|
assert eval_calls == 0
|
|
|
|
|
|
def test_repeated_fit_reinitializes_from_original_constructor_model_and_clears_early_stop(
|
|
model_factory, xor_data, monkeypatch
|
|
):
|
|
"""Verify repeated fit reinitializes base vector from original constructor model and clears early stopping state."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
orig_constructor_weights = torch.cat([p.detach().flatten() for p in model.parameters()]).clone()
|
|
|
|
opt = Optimizer(
|
|
model, loss, task="binary",
|
|
convergence="early_stopping",
|
|
convergence_patience=2,
|
|
seed=42,
|
|
)
|
|
|
|
score1 = opt.fit(x, y, epochs=3)
|
|
assert opt._global_best_weights is not None
|
|
first_run_best = opt._global_best_weights.clone()
|
|
|
|
early_plugin = opt.convergence_plugin
|
|
prepared_cleared = False
|
|
orig_prep = early_plugin.prepare_fit
|
|
|
|
def mock_prep(ctx):
|
|
nonlocal prepared_cleared
|
|
orig_prep(ctx)
|
|
if early_plugin.best_gbest_monitor is None and early_plugin.gbest_patience == 0:
|
|
prepared_cleared = True
|
|
|
|
monkeypatch.setattr(early_plugin, "prepare_fit", mock_prep)
|
|
|
|
# Second fit on same Optimizer instance
|
|
score2 = opt.fit(x, y, epochs=3)
|
|
|
|
# Particles in second fit must be initialized from orig_constructor_weights, NOT first_run_best
|
|
for p in opt.particles:
|
|
diff_from_orig = torch.norm(p.position.cpu() - orig_constructor_weights.cpu())
|
|
assert diff_from_orig < 5.0, "Particle position diverged from original constructor model base"
|
|
|
|
# Early stopping plugin state was cleared during prepare_fit
|
|
assert prepared_cleared is True
|
|
|
|
|
|
def test_particle_reset_uses_original_constructor_base_vector(
|
|
model_factory, xor_data
|
|
):
|
|
"""Verify particle reset re-initializes position from the original constructor base_vector."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
orig_base = torch.cat([p.detach().flatten() for p in model.parameters()]).clone()
|
|
|
|
opt = Optimizer(
|
|
model, loss, task="binary",
|
|
convergence="particle_reset",
|
|
convergence_patience=1,
|
|
seed=42,
|
|
)
|
|
opt.fit(x, y, epochs=3)
|
|
|
|
for p in opt.particles:
|
|
assert p.position.shape == orig_base.shape
|
|
|
|
|
|
def test_inertia_schedule_bounds_and_last_movement_w_min(model_factory, xor_data, monkeypatch):
|
|
"""Verify inertia weight w stays in [w_min, w_max], starts at w_max and last movement reaches w_min."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
w_min_val, w_max_val = 0.1, 0.9
|
|
opt = Optimizer(
|
|
model, loss, task="binary", method="inertia",
|
|
w_min=w_min_val, w_max=w_max_val, n_particles=2, seed=42
|
|
)
|
|
|
|
recorded_w = []
|
|
orig_propose = opt.movement_plugin.propose
|
|
|
|
def mock_propose(particle_idx, state, context):
|
|
recorded_w.append(context.w)
|
|
return orig_propose(particle_idx, state, context)
|
|
|
|
monkeypatch.setattr(opt.movement_plugin, "propose", mock_propose)
|
|
|
|
epochs = 5
|
|
opt.fit(x, y, epochs=epochs)
|
|
|
|
# 2 particles per epoch * 4 epochs of movement = 8 propose calls
|
|
assert len(recorded_w) == 8
|
|
|
|
for w in recorded_w:
|
|
assert w_min_val <= w <= w_max_val
|
|
|
|
# Epoch 0 (first movement) uses w_max
|
|
assert math.isclose(recorded_w[0], w_max_val)
|
|
assert math.isclose(recorded_w[1], w_max_val)
|
|
|
|
# Epoch 3 (last movement before epoch 4 final evaluation) uses w_min
|
|
assert math.isclose(recorded_w[-1], w_min_val)
|
|
assert math.isclose(recorded_w[-2], w_min_val)
|
|
def test_two_epoch_inertia_sole_movement_gets_w_max(model_factory, xor_data, monkeypatch):
|
|
"""Verify in a 2-epoch run with inertia method, the sole movement step gets w_max."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
w_min_val, w_max_val = 0.1, 0.9
|
|
opt = Optimizer(
|
|
model, loss, task="binary", method="inertia",
|
|
w_min=w_min_val, w_max=w_max_val, n_particles=2, seed=42
|
|
)
|
|
|
|
recorded_w = []
|
|
orig_propose = opt.movement_plugin.propose
|
|
|
|
def mock_propose(particle_idx, state, context):
|
|
recorded_w.append(context.w)
|
|
return orig_propose(particle_idx, state, context)
|
|
|
|
monkeypatch.setattr(opt.movement_plugin, "propose", mock_propose)
|
|
opt.fit(x, y, epochs=2)
|
|
|
|
# 2-epoch fit has 1 movement step (epoch 0) across 2 particles = 2 propose calls
|
|
assert len(recorded_w) == 2
|
|
for w in recorded_w:
|
|
assert math.isclose(w, w_max_val)
|
|
|
|
|
|
def test_standard_fit_no_swarm_snapshot_stacks(model_factory, xor_data, monkeypatch):
|
|
"""Verify standard method fit does not perform swarm [N, D] snapshot stacks while preserving correct fit behavior."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(
|
|
model, loss, task="binary", method="inertia", n_particles=4, seed=42
|
|
)
|
|
|
|
orig_stack = torch.stack
|
|
snapshot_stack_calls = []
|
|
|
|
def mock_stack(tensors, dim=0, *args, **kwargs):
|
|
if isinstance(tensors, (list, tuple)) and len(tensors) == opt.n_particles:
|
|
first = tensors[0]
|
|
if isinstance(first, torch.Tensor) and first.ndim == 1:
|
|
snapshot_stack_calls.append(len(tensors))
|
|
return orig_stack(tensors, dim=dim, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(torch, "stack", mock_stack)
|
|
|
|
score = opt.fit(x, y, epochs=3)
|
|
assert len(snapshot_stack_calls) == 0, f"Expected 0 swarm snapshot stacks, got {len(snapshot_stack_calls)}"
|
|
assert isinstance(score, tuple) and len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
assert opt.get_best_model() is not None
|
|
|
|
|
|
def test_optimizer_public_evaluate_contract(model_factory, xor_data):
|
|
"""Verify public Optimizer.evaluate method raises pre-fit RuntimeError and has shape/type/aggregate parity for binary and multiclass."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt_binary = Optimizer(model, loss, task="binary", n_particles=2, seed=42)
|
|
|
|
# Pre-fit failure
|
|
with pytest.raises(RuntimeError, match="(?i)(not available|not been run)"):
|
|
opt_binary.evaluate(x, y)
|
|
|
|
# Binary evaluation parity
|
|
fit_score = opt_binary.fit(x, y, epochs=2)
|
|
eval_score = opt_binary.evaluate(x, y)
|
|
|
|
assert isinstance(eval_score, tuple) and len(eval_score) == 3
|
|
assert all(isinstance(s, float) and math.isfinite(s) for s in eval_score)
|
|
assert math.isclose(eval_score[0], fit_score[0], abs_tol=1e-5)
|
|
assert math.isclose(eval_score[1], fit_score[1], abs_tol=1e-5)
|
|
assert math.isclose(eval_score[2], fit_score[2], abs_tol=1e-5)
|
|
|
|
# Binary evaluation with batching
|
|
eval_batched = opt_binary.evaluate(x, y, batch_size=2)
|
|
assert isinstance(eval_batched, tuple) and len(eval_batched) == 3
|
|
assert all(isinstance(s, float) and math.isfinite(s) for s in eval_batched)
|
|
|
|
# Multiclass evaluation parity
|
|
mc_model = model_factory(input_dim=4, output_dim=3)
|
|
mc_loss = nn.CrossEntropyLoss()
|
|
opt_mc = Optimizer(mc_model, mc_loss, task="multiclass", n_particles=2, seed=42)
|
|
|
|
torch.manual_seed(42)
|
|
x_mc = torch.randn(6, 4)
|
|
y_mc_1d = torch.tensor([0, 1, 2, 0, 1, 2], dtype=torch.int64)
|
|
y_mc_2d = torch.nn.functional.one_hot(y_mc_1d, num_classes=3).float()
|
|
|
|
opt_mc.fit(x_mc, y_mc_1d, epochs=2)
|
|
score_1d = opt_mc.evaluate(x_mc, y_mc_1d)
|
|
score_2d = opt_mc.evaluate(x_mc, y_mc_2d)
|
|
|
|
assert isinstance(score_1d, tuple) and len(score_1d) == 3
|
|
assert isinstance(score_2d, tuple) and len(score_2d) == 3
|
|
assert all(isinstance(s, float) and math.isfinite(s) for s in score_1d)
|
|
assert all(isinstance(s, float) and math.isfinite(s) for s in score_2d)
|
|
assert math.isclose(score_1d[0], score_2d[0], abs_tol=1e-5)
|
|
assert math.isclose(score_1d[1], score_2d[1], abs_tol=1e-5)
|
|
def test_fixed_subset_size_supplied_only_to_fit(model_factory, xor_data):
|
|
"""Verify evaluation='fixed_subset' allows omitting fitness_size at Optimizer init and supplying it at fit."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", evaluation="fixed_subset")
|
|
assert opt.fitness_size is None
|
|
|
|
score = opt.fit(x, y, epochs=2, fitness_size=2)
|
|
assert isinstance(score, tuple) and len(score) == 3
|
|
assert all(math.isfinite(s) for s in score)
|
|
|
|
# Omission at both init and fit fails during fit
|
|
opt2 = Optimizer(model, loss, task="binary", evaluation="fixed_subset")
|
|
with pytest.raises(ValueError, match="requires a positive fitness_size"):
|
|
opt2.fit(x, y, epochs=2)
|
|
|
|
|
|
def test_final_evaluation_skips_movement_on_epoch_end_and_pbest_stacking(
|
|
model_factory, xor_data, monkeypatch
|
|
):
|
|
"""Verify final evaluation pass does not trigger movement on_epoch_end or stack CLPSO pbests."""
|
|
x, y = xor_data
|
|
model = model_factory()
|
|
loss = nn.BCEWithLogitsLoss()
|
|
|
|
opt = Optimizer(model, loss, task="binary", method="clpso", n_particles=3, seed=42)
|
|
|
|
epoch_end_calls = 0
|
|
orig_on_epoch_end = opt.movement_plugin.on_epoch_end
|
|
|
|
def spy_on_epoch_end(state, context):
|
|
nonlocal epoch_end_calls
|
|
epoch_end_calls += 1
|
|
return orig_on_epoch_end(state, context)
|
|
|
|
monkeypatch.setattr(opt.movement_plugin, "on_epoch_end", spy_on_epoch_end)
|
|
|
|
epochs = 3
|
|
opt.fit(x, y, epochs=epochs)
|
|
|
|
# For 3 epochs, on_epoch_end should be called between epochs (epochs - 1 = 2 times)
|
|
assert epoch_end_calls == 2, f"Expected 2 inter-epoch on_epoch_end calls for 3 epochs, got {epoch_end_calls}"
|