algorise's picture
download
raw
17.6 kB
#!/usr/bin/env python3
"""DDSVM reproduction v2 -- multi-seed, multi-dataset, pre-registered predicates.
Replaces the v1 single-seed toy run (repro_ddsvm.py, seed 42, one easy moons
dataset, ~5 s). v2 design, fixed BEFORE running:
Datasets (per-seed fresh sample / fresh split, hash-verified distinct):
moons-hard : sklearn make_moons, n=3000 (train 2000 / test 1000), noise 0.30
rings : sklearn make_circles, n=3000 (2000/1000), noise 0.20, factor 0.55
gauss-xor : 2-D XOR of 4 Gaussians (sigma 0.75) + 8 nuisance dims, 2000/1000
digits-3v8 : sklearn load_digits classes {3,8}, per-seed stratified split
train 120 / test ~237 (small-sample regime, 64 features)
Methods (matched 300-epoch budget for all deep models, same backbone d-64-64-16):
linear-svm : sklearn LinearSVC on standardized raw features (classical baseline)
rbf-svm : sklearn SVC(RBF) (classical baseline)
deep-ce : backbone + linear head, cross-entropy, Adam 5e-3, 300 epochs
deep-svm : backbone + linear SVM head, hinge + 0.01*0.5||w||^2, Adam, 300 ep
ddsvm : 15 cycles x (8 ep Phase A feat | 8 ep Phase B head | Phase C push
along n = w/||w|| on active set gamma<1, eta 0.08, + 4 ep align)
ddsvm-rand : identical to ddsvm but Phase C pushes along a RANDOM unit vector
(fresh per cycle) instead of the boundary normal -- the control
that makes "geometry-aware" falsifiable.
Seeds: 25 per (dataset x method). Every RNG derived from the seed; SHA1 of the
train matrix recorded per seed; run-integrity block asserts all hashes distinct
and per-method accuracy variance > 0 (guards the constructed-but-never-sampled
RandomState bug found in a sibling reproduction).
PRE-STATED PREDICATES (two-sided / falsifiable, fixed before the run):
Claim 1 (per dataset):
P1a structural: in EVERY cycle, Phase A changes (w,b) by exactly 0 and Phase B
changes theta by exactly 0, and Phase C mean feature displacement > 0.
P1b convergence: mean end-of-cycle train hinge, cycle15/cycle1 ratio: 95% CI
(over seeds) entirely <= 0.5, AND final mean hinge > 1e-8 (degeneracy guard).
P1c trend: OLS of log(mean hinge) vs cycle: slope <= -0.05 AND R^2 >= 0.70.
Verdict: VERIFIED if >=10/12 dataset-predicate checks pass AND P1a passes on
all 4 datasets; PARTIAL if 6-9; else NOT REPRODUCED.
Claim 2 (per dataset):
P2a direction: mean cosine(achieved active-point displacement, y_i * n) over
cycles, per-seed; dataset 95% CI lower bound >= 0.5.
P2b per-cycle effect: paired (post - pre) refinement change of active-set mean
margin, pooled over cycles per seed; dataset mean > 0 with 95% CI excl. 0.
P2c end-to-end: (final post-refinement all-point train margin) - (cycle-1
pre-refinement margin) > 0 with 95% CI excl. 0, AND train margin violators
(gamma<1) decrease cycle1 -> final in >= 80% of seeds.
P2d geometry-aware ablation: paired (ddsvm - ddsvm-rand) final normalized test
margin > 0 with 95% CI excl. 0.
Verdict: VERIFIED if >=13/16 checks pass; PARTIAL if 8-12; else NOT REPRODUCED.
Claim 3 (per dataset, paired over seeds, vs the two deep baselines):
P3 VERIFIED iff on >= 2/4 datasets BOTH paired accuracy differences
(ddsvm - deep-ce) and (ddsvm - deep-svm) are > 0 with 95% CI excluding 0,
AND no dataset has a 95% CI entirely below 0 vs either baseline.
NOT REPRODUCED iff zero datasets show a CI-excluding-0 improvement vs either
deep baseline, OR >= 2 datasets show a CI entirely below 0 (regression).
Otherwise PARTIAL.
Outputs: results/v2/ddsvm_v2_<dataset>.json (per-seed raw), merged summary via
merge step in analyze_v2.py.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import time
import numpy as np
import torch
import torch.nn as nn
# Single-thread torch: benchmarked 9.4 ms/epoch vs 218 ms/epoch with 2 threads
# on this Windows CPU (tiny full-batch models are per-op-overhead dominated).
torch.set_num_threads(1)
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUTDIR = os.path.join(ROOT, "results", "v2")
os.makedirs(OUTDIR, exist_ok=True)
N_SEEDS = 25
N_CYCLES = 15
EP_A, EP_B, EP_C = 8, 8, 4 # 15*(8+8+4) = 300 epoch budget
EPOCHS_JOINT = 300 # matched budget for deep-ce / deep-svm
ETA_GEOM = 0.08
MARGIN_TARGET = 1.0
LR_FEAT, LR_HEAD, LR_JOINT = 5e-3, 1e-2, 5e-3
WD = 1e-4
REG_W = 0.01
DATASETS = ["moons-hard", "rings", "gauss-xor", "digits-3v8"]
# ----------------------------------------------------------------- datasets --
def make_dataset(name: str, seed: int):
"""Fresh sample / fresh split per seed. Returns standardized tensors."""
rng = np.random.default_rng(100_000 + seed)
if name == "moons-hard":
from sklearn.datasets import make_moons
X, y = make_moons(n_samples=3000, noise=0.30,
random_state=int(rng.integers(2**31 - 1)))
n_tr = 2000
elif name == "rings":
from sklearn.datasets import make_circles
X, y = make_circles(n_samples=3000, noise=0.20, factor=0.55,
random_state=int(rng.integers(2**31 - 1)))
n_tr = 2000
elif name == "gauss-xor":
n, sigma, d_noise = 3000, 0.75, 8
centers = np.array([[1, 1], [-1, -1], [1, -1], [-1, 1]], dtype=float)
labels = np.array([1, 1, 0, 0])
idx = rng.integers(0, 4, size=n)
X2 = centers[idx] + rng.normal(0, sigma, size=(n, 2))
Xn = rng.normal(0, 1.0, size=(n, d_noise))
X = np.hstack([X2, Xn])
y = labels[idx]
n_tr = 2000
elif name == "digits-3v8":
from sklearn.datasets import load_digits
D = load_digits()
mask = np.isin(D.target, [3, 8])
X, y = D.data[mask], (D.target[mask] == 8).astype(int)
# stratified per-seed split, train 120
idx0 = np.where(y == 0)[0]
idx1 = np.where(y == 1)[0]
rng.shuffle(idx0)
rng.shuffle(idx1)
tr = np.concatenate([idx0[:60], idx1[:60]])
te = np.concatenate([idx0[60:], idx1[60:]])
rng.shuffle(tr)
rng.shuffle(te)
Xtr, Xte = X[tr], X[te]
ytr, yte = y[tr], y[te]
return _standardize_pack(Xtr, ytr, Xte, yte)
else:
raise ValueError(name)
perm = rng.permutation(len(X))
X, y = X[perm], y[perm]
return _standardize_pack(X[:n_tr], y[:n_tr], X[n_tr:], y[n_tr:])
def _standardize_pack(Xtr, ytr, Xte, yte):
mu, sd = Xtr.mean(0), Xtr.std(0) + 1e-8
Xtr = (Xtr - mu) / sd
Xte = (Xte - mu) / sd
h = hashlib.sha1(np.ascontiguousarray(Xtr).tobytes()).hexdigest()[:12]
to = lambda a: torch.tensor(a, dtype=torch.float32)
ypm = lambda a: torch.tensor(2.0 * a - 1.0, dtype=torch.float32) # {0,1}->{-1,+1}
return dict(Xtr=to(Xtr), ytr=ypm(ytr), Xte=to(Xte), yte=ypm(yte),
ytr01=torch.tensor(ytr, dtype=torch.long),
yte01=torch.tensor(yte, dtype=torch.long), hash=h)
# ------------------------------------------------------------------- models --
class Backbone(nn.Module):
def __init__(self, d_in, hidden=64, d_feat=16):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_in, hidden), nn.GELU(),
nn.Linear(hidden, hidden), nn.GELU(),
nn.Linear(hidden, d_feat))
def forward(self, x):
return self.net(x)
class SVMHead(nn.Module):
def __init__(self, d_feat=16):
super().__init__()
self.w = nn.Parameter(torch.randn(d_feat, 1) * 0.01)
self.b = nn.Parameter(torch.zeros(1))
def forward(self, z):
return (z @ self.w + self.b).squeeze(-1)
def geo_margin(z, y, w, b):
return y * ((z @ w).squeeze(-1) + b) / (torch.norm(w) + 1e-12)
def norm_margin(z, y, w, b):
"""Scale-invariant margin: geometric margin / RMS feature norm."""
g = geo_margin(z, y, w, b)
rms = torch.sqrt((z ** 2).sum(1).mean()) + 1e-12
return g / rms
def flat(params):
return torch.cat([p.detach().reshape(-1).clone() for p in params])
# ----------------------------------------------------------- method runners --
def run_deep_ce(data, seed):
torch.manual_seed(20_000 + seed)
d_in = data["Xtr"].shape[1]
feat, head = Backbone(d_in), nn.Linear(16, 2)
opt = torch.optim.Adam(list(feat.parameters()) + list(head.parameters()),
lr=LR_JOINT)
lossf = nn.CrossEntropyLoss()
for _ in range(EPOCHS_JOINT):
opt.zero_grad()
loss = lossf(head(feat(data["Xtr"])), data["ytr01"])
loss.backward()
opt.step()
with torch.no_grad():
pred = head(feat(data["Xte"])).argmax(1)
acc = (pred == data["yte01"]).float().mean().item()
return dict(acc=acc)
def run_deep_svm(data, seed):
torch.manual_seed(30_000 + seed)
d_in = data["Xtr"].shape[1]
feat, head = Backbone(d_in), SVMHead()
opt = torch.optim.Adam(list(feat.parameters()) + list(head.parameters()),
lr=LR_JOINT, weight_decay=WD)
for _ in range(EPOCHS_JOINT):
opt.zero_grad()
s = head(feat(data["Xtr"]))
loss = torch.clamp(MARGIN_TARGET - data["ytr"] * s, min=0).mean() \
+ REG_W * 0.5 * torch.norm(head.w) ** 2
loss.backward()
opt.step()
with torch.no_grad():
zte = feat(data["Xte"])
acc = (torch.sign(head(zte)) == data["yte"]).float().mean().item()
nm = norm_margin(zte, data["yte"], head.w, head.b)
return dict(acc=acc, test_norm_margin_mean=nm.mean().item(),
test_norm_margin_p10=nm.quantile(0.10).item())
def run_ddsvm(data, seed, random_push=False):
torch.manual_seed((50_000 if not random_push else 60_000) + seed)
g_push = torch.Generator().manual_seed(70_000 + seed)
d_in = data["Xtr"].shape[1]
feat, head = Backbone(d_in), SVMHead()
opt_f = torch.optim.Adam(feat.parameters(), lr=LR_FEAT)
opt_h = torch.optim.Adam(head.parameters(), lr=LR_HEAD, weight_decay=WD)
Xtr, ytr = data["Xtr"], data["ytr"]
cyc = []
for c in range(N_CYCLES):
# Phase A: representation learning (head frozen)
head_before = flat(head.parameters())
feat_before = flat(feat.parameters())
for _ in range(EP_A):
opt_f.zero_grad()
s = head(feat(Xtr))
hin = torch.clamp(MARGIN_TARGET - ytr * s, min=0).mean()
hin.backward()
opt_f.step()
head_drift_A = torch.norm(flat(head.parameters()) - head_before).item()
feat_move_A = torch.norm(flat(feat.parameters()) - feat_before).item()
with torch.no_grad():
hinge_A = torch.clamp(
MARGIN_TARGET - ytr * head(feat(Xtr)), min=0).mean().item()
# Phase B: boundary optimization (features frozen/detached)
feat_before = flat(feat.parameters())
head_before = flat(head.parameters())
with torch.no_grad():
z_fix = feat(Xtr)
for _ in range(EP_B):
opt_h.zero_grad()
s = head(z_fix)
loss = torch.clamp(MARGIN_TARGET - ytr * s, min=0).mean() \
+ REG_W * 0.5 * torch.norm(head.w) ** 2
loss.backward()
opt_h.step()
feat_drift_B = torch.norm(flat(feat.parameters()) - feat_before).item()
head_move_B = torch.norm(flat(head.parameters()) - head_before).item()
with torch.no_grad():
hinge_B = torch.clamp(
MARGIN_TARGET - ytr * head(feat(Xtr)), min=0).mean().item()
# Phase C: geometry-aware feature refinement
with torch.no_grad():
z_in = feat(Xtr)
n_vec = (head.w / (torch.norm(head.w) + 1e-12)).squeeze(-1)
if random_push:
u = torch.randn(n_vec.shape[0], generator=g_push)
n_used = u / (torch.norm(u) + 1e-12)
else:
n_used = n_vec
m_pre = geo_margin(z_in, ytr, head.w, head.b)
active = (m_pre < MARGIN_TARGET)
direction = ytr.unsqueeze(1) * n_used.unsqueeze(0)
z_target = z_in + ETA_GEOM * direction * active.float().unsqueeze(1)
viol_pre = int((m_pre < MARGIN_TARGET).sum().item())
for _ in range(EP_C):
opt_f.zero_grad()
loss_g = nn.functional.mse_loss(feat(Xtr), z_target)
loss_g.backward()
opt_f.step()
with torch.no_grad():
z_out = feat(Xtr)
m_post = geo_margin(z_out, ytr, head.w, head.b)
dz = z_out - z_in
disp = torch.norm(dz, dim=1)
# cosine of achieved displacement with prescribed direction, active pts
if active.any():
cos = nn.functional.cosine_similarity(
dz[active], direction[active], dim=1)
cos_mean = cos.mean().item()
d_active = (m_post[active] - m_pre[active]).mean().item()
else:
cos_mean, d_active = float("nan"), float("nan")
hinge_C = torch.clamp(
MARGIN_TARGET - ytr * head(feat(Xtr)), min=0).mean().item()
viol_post = int((m_post < MARGIN_TARGET).sum().item())
cyc.append(dict(
cycle=c + 1, hinge_A=hinge_A, hinge_B=hinge_B, hinge_end=hinge_C,
head_drift_A=head_drift_A, feat_move_A=feat_move_A,
feat_drift_B=feat_drift_B, head_move_B=head_move_B,
mean_disp_C=disp.mean().item(),
active_frac=active.float().mean().item(),
cos_active=cos_mean,
margin_pre_mean=m_pre.mean().item(),
margin_post_mean=m_post.mean().item(),
margin_pre_active_mean=(m_pre[active].mean().item()
if active.any() else float("nan")),
margin_post_active_mean=(m_post[active].mean().item()
if active.any() else float("nan")),
delta_margin_active=d_active,
viol_pre=viol_pre, viol_post=viol_post))
with torch.no_grad():
zte = feat(data["Xte"])
acc = (torch.sign(head(zte)) == data["yte"]).float().mean().item()
nm = norm_margin(zte, data["yte"], head.w, head.b)
return dict(acc=acc, test_norm_margin_mean=nm.mean().item(),
test_norm_margin_p10=nm.quantile(0.10).item(), cycles=cyc)
def run_sklearn_svm(data, seed, kind):
Xtr = data["Xtr"].numpy()
Xte = data["Xte"].numpy()
ytr = data["ytr01"].numpy()
yte = data["yte01"].numpy()
if kind == "linear":
from sklearn.svm import LinearSVC
clf = LinearSVC(C=1.0, max_iter=2000, dual=False, random_state=seed)
else:
from sklearn.svm import SVC
clf = SVC(C=1.0, kernel="rbf", gamma="scale", random_state=seed)
clf.fit(Xtr, ytr)
return dict(acc=float(clf.score(Xte, yte)))
# --------------------------------------------------------------------- main --
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--datasets", nargs="+", default=DATASETS)
ap.add_argument("--seeds", type=int, default=N_SEEDS)
args = ap.parse_args()
for ds in args.datasets:
t0 = time.time()
rows = []
for seed in range(args.seeds):
data = make_dataset(ds, seed)
r = dict(seed=seed, data_hash=data["hash"],
n_train=int(data["Xtr"].shape[0]),
n_test=int(data["Xte"].shape[0]),
d=int(data["Xtr"].shape[1]))
r["linear-svm"] = run_sklearn_svm(data, seed, "linear")
r["rbf-svm"] = run_sklearn_svm(data, seed, "rbf")
r["deep-ce"] = run_deep_ce(data, seed)
r["deep-svm"] = run_deep_svm(data, seed)
r["ddsvm"] = run_ddsvm(data, seed, random_push=False)
r["ddsvm-rand"] = run_ddsvm(data, seed, random_push=True)
rows.append(r)
print(f"[{ds}] seed {seed:2d} hash={data['hash']} "
f"acc: lin={r['linear-svm']['acc']:.4f} "
f"rbf={r['rbf-svm']['acc']:.4f} "
f"ce={r['deep-ce']['acc']:.4f} "
f"dsvm={r['deep-svm']['acc']:.4f} "
f"ddsvm={r['ddsvm']['acc']:.4f} "
f"rand={r['ddsvm-rand']['acc']:.4f}", flush=True)
wall = time.time() - t0
hashes = [r["data_hash"] for r in rows]
integrity = dict(
n_seeds=args.seeds,
n_unique_data_hashes=len(set(hashes)),
all_hashes_distinct=len(set(hashes)) == args.seeds,
acc_std_by_method={m: float(np.std([r[m]["acc"] for r in rows]))
for m in ["linear-svm", "rbf-svm", "deep-ce",
"deep-svm", "ddsvm", "ddsvm-rand"]})
out = dict(dataset=ds, wall_time_sec=wall, config=dict(
n_seeds=args.seeds, n_cycles=N_CYCLES, ep_a=EP_A, ep_b=EP_B,
ep_c=EP_C, epochs_joint=EPOCHS_JOINT, eta_geom=ETA_GEOM,
lr_feat=LR_FEAT, lr_head=LR_HEAD, lr_joint=LR_JOINT,
weight_decay=WD, reg_w=REG_W), integrity=integrity, runs=rows)
path = os.path.join(OUTDIR, f"ddsvm_v2_{ds}.json")
with open(path, "w") as f:
json.dump(out, f)
print(f"[{ds}] DONE in {wall:.1f}s -> {path}")
print(f"[{ds}] integrity: {integrity['n_unique_data_hashes']}/"
f"{args.seeds} unique hashes; "
f"acc std ddsvm={integrity['acc_std_by_method']['ddsvm']:.5f}")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
17.6 kB
·
Xet hash:
168c8f362bb7b2bae277295bfcb6d50b36293cdb3f0c9af3bc244922a18eb6d1

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.