refactor: 将子模块转为普通目录,移除外部 git 依赖
Sync to site1 / sync (push) Has been cancelled

- 移除 JEPA/lejepa-identifiability 子模块 gitlink
- 移除 research/multiply/MultiPLY 子模块 gitlink
- 删除 .gitmodules(不再有外部 URL 依赖)
- 两个目录内容作为普通文件纳入主仓库追踪
- 删除各自内部 .git 目录,消除嵌套 git 仓库
This commit is contained in:
gaojie
2026-06-05 17:14:01 +08:00
parent cb629f18a1
commit c66855adfc
208 changed files with 23296 additions and 9 deletions
@@ -0,0 +1,36 @@
"""Data generation: latent sources and OU augmentation."""
import math
import torch
def _gennorm_unit_var_scale(alpha):
"""Scale β so gennorm(α, β) has unit variance: β = sqrt(Γ(1/α) / Γ(3/α))."""
return math.exp(0.5 * (math.lgamma(1.0 / alpha) - math.lgamma(3.0 / alpha)))
def sample_latents(D, N, dist="gaussian", device="cuda", alpha=None):
"""Sample D points in R^N (unit variance)."""
if dist == "gaussian":
return torch.randn(D, N, device=device)
elif dist == "laplace":
return torch.distributions.Laplace(0, 1 / (2 ** 0.5)).sample((D, N)).to(device)
elif dist == "gennorm":
if alpha is None:
raise ValueError("gennorm requires alpha")
scale = _gennorm_unit_var_scale(alpha)
u = torch.distributions.Gamma(1.0 / alpha, 1.0).sample((D, N)).to(device)
sign = torch.randint(0, 2, (D, N), device=device).float() * 2 - 1
return scale * sign * u.pow(1.0 / alpha)
else:
raise ValueError(f"Unknown distribution: {dist}")
def ou_augment(z, rho, n_views=2, dist="gaussian", alpha=None):
"""OU channel: z' = ρz + √(1-ρ²)η, η drawn from same dist as source.
Returns (V, B, N)."""
fac = (1 - rho ** 2) ** 0.5
D, N = z.shape
eta = sample_latents(n_views * D, N, dist=dist, device=z.device, alpha=alpha)
eta = eta.reshape(n_views, D, N)
return rho * z.unsqueeze(0) + fac * eta
@@ -0,0 +1,143 @@
"""
Core training engine — single function used by all experiments.
Handles: LR schedule (warmup + cosine), online data generation,
loss computation (lejepa or whiten), periodic evaluation of ALL metrics
on a fixed eval set, standardized output schema.
"""
import torch
import numpy as np
from .losses import SIGReg, whitening_loss, alignment_loss, infonce_loss
from .data import sample_latents, ou_augment
from .metrics import compute_all_metrics
def warmup_cosine_lr(step, total_steps, base_lr):
"""Constant for first half, cosine decay for second half."""
warmup = total_steps // 2
if step < warmup:
return base_lr
t = (step - warmup) / (total_steps - warmup)
return base_lr * 0.5 * (1 + np.cos(np.pi * t))
def train_and_evaluate(
encoder,
mix_fn,
*,
N,
rho,
lamb,
sigma=1.0,
mode="lejepa",
source_dist="gaussian",
source_alpha=None,
steps=20000,
batch_size=256,
lr=3e-3,
z_eval,
log_every=100,
device="cuda",
):
"""Train encoder and evaluate periodically.
Args:
encoder: nn.Module, x -> h
mix_fn: callable, z -> x
N: latent dimension
rho: OU correlation
lamb: regularization weight
mode: "lejepa" or "whiten"
source_dist: "gaussian", "laplace", or "gennorm"
steps: total training steps
batch_size: batch size (online data)
lr: peak learning rate
z_eval: (num_eval, N) fixed eval tensor
log_every: eval frequency
device: torch device string
Returns:
encoder: trained encoder
log: dict of lists — training curves and periodic eval metrics
"""
sigreg = SIGReg().to(device)
opt = torch.optim.AdamW(encoder.parameters(), lr=lr)
# Precompute eval mixing (constant across training)
x_eval = mix_fn(z_eval)
log_keys = [
"step", "lr",
# training losses
"align", "sigreg", "whiten", "total",
# eval metrics
"r2_zx", "r2_xz", "r2_zh", "r2_hz",
"orth_err", "orth_err_normalized",
"epsilon", "delta", "D_bound", "approx_bound",
"procrustes_mse", "L_h", "trace_cov",
]
log = {k: [] for k in log_keys}
for step in range(steps + 1):
# LR schedule
current_lr = warmup_cosine_lr(step, steps, lr)
for pg in opt.param_groups:
pg["lr"] = current_lr
# Online data
z_batch = sample_latents(batch_size, N, dist=source_dist,
device=device, alpha=source_alpha)
z_aug = ou_augment(z_batch, rho, dist=source_dist, alpha=source_alpha) # (2, B, N)
h = encoder(mix_fn(z_aug).flatten(0, 1)).reshape(2, batch_size, N)
align = alignment_loss(h)
sig = sigreg(h)
wht = whitening_loss(h)
if mode == "lejepa":
loss = lamb * sig + (1 - lamb) * align
elif mode == "whiten":
loss = lamb * wht + (1 - lamb) * align
elif mode == "infonce":
loss = infonce_loss(h, sigma)
else:
raise ValueError(f"Unknown mode: {mode}")
opt.zero_grad()
loss.backward()
opt.step()
if step % log_every == 0 or (step < 1000 and step % 100 == 0):
log["step"].append(step)
log["lr"].append(current_lr)
log["align"].append(align.item())
log["sigreg"].append(sig.item())
log["whiten"].append(wht.item())
log["total"].append(loss.item())
# Full eval on fixed set
encoder.eval()
with torch.no_grad():
h_eval = encoder(x_eval)
z_prime = ou_augment(
z_eval, rho, n_views=1,
dist=source_dist, alpha=source_alpha
).squeeze(0)
h_prime = encoder(mix_fn(z_prime))
metrics = compute_all_metrics(z_eval, x_eval, h_eval, h_prime, rho, N)
for k, v in metrics.items():
log[k].append(v)
encoder.train()
if step % (log_every * 10) == 0:
print(f" step {step:5d} | lr={current_lr:.1e} "
f"align={align.item():.2e} sig={sig.item():.1f} "
f"R²(h->z)={metrics['r2_hz']:.4f} "
f"orth={metrics['orth_err']:.4f}")
return encoder, log
@@ -0,0 +1,53 @@
"""Loss functions."""
import torch
import torch.nn as nn
import torch.nn.functional as F
class SIGReg(nn.Module):
"""Sliced characteristic function regularizer (Balestriero & LeCun 2025)."""
def __init__(self, knots=17, n_slices=256, t_max=3.0):
super().__init__()
self.n_slices = n_slices
t = torch.linspace(0, t_max, knots)
dt = t_max / (knots - 1)
w = torch.full((knots,), 2 * dt)
w[[0, -1]] = dt
self.register_buffer("t", t)
self.register_buffer("phi", torch.exp(-t**2 / 2))
self.register_buffer("weights", w * torch.exp(-t**2 / 2))
def forward(self, h):
"""h: (V, B, N) -> scalar."""
flat = h.flatten(0, 1)
A = F.normalize(torch.randn(flat.size(-1), self.n_slices, device=flat.device), dim=0)
xt = (flat @ A).unsqueeze(-1) * self.t
err = (xt.cos().mean(0) - self.phi) ** 2 + xt.sin().mean(0) ** 2
return (err @ self.weights).mean() * flat.size(0)
def whitening_loss(h):
"""||Cov(h) - I||²_F. h: (V, B, N) -> scalar."""
flat = h.flatten(0, 1)
flat = flat - flat.mean(dim=0)
cov = (flat.T @ flat) / (flat.shape[0] - 1)
return (cov - torch.eye(flat.shape[1], device=h.device)).square().mean()
def alignment_loss(h):
"""Pull positive-pair views together. h: (V, B, N) -> scalar."""
return (h.mean(0) - h).square().mean()
def infonce_loss(h, sigma):
"""Symmetric Gaussian-kernel InfoNCE: sim(u, v) = -||u - v||² / (2σ²).
h: (V, B, N) with V=2 views. Negatives are other batch elements.
"""
h1, h2 = h[0], h[1] # (B, N) each
d12 = ((h1.unsqueeze(1) - h2.unsqueeze(0)) ** 2).sum(-1) # (B, B)
sim = -d12 / (2 * sigma ** 2)
loss_a = -(sim.diag() - torch.logsumexp(sim, dim=1)).mean()
loss_b = -(sim.diag() - torch.logsumexp(sim, dim=0)).mean()
return 0.5 * (loss_a + loss_b)
@@ -0,0 +1,65 @@
"""Evaluation metrics — standardized across all experiments."""
import torch
def bidirectional_r2(a, b):
"""R²(a->b) and R²(b->a) via torch lstsq on GPU. a, b are tensors."""
def _r2(x, y):
x1 = torch.cat([x, torch.ones(len(x), 1, device=x.device)], dim=1)
W = torch.linalg.lstsq(x1, y).solution
ss_res = ((y - x1 @ W) ** 2).sum()
ss_tot = ((y - y.mean(0)) ** 2).sum()
return (1 - ss_res / ss_tot).item()
return _r2(a, b), _r2(b, a)
def compute_all_metrics(z, x, h, h_prime, rho, N):
"""All metrics on GPU. z, x, h, h_prime are torch tensors."""
r2_zx, r2_xz = bidirectional_r2(z, x)
r2_zh, r2_hz = bidirectional_r2(z, h)
# Orthogonality
z1 = torch.cat([z, torch.ones(len(z), 1, device=z.device)], dim=1)
W = torch.linalg.lstsq(z1, h).solution
A = W[:N].T
orth_err = torch.linalg.norm(A.T @ A - torch.eye(N, device=A.device), 'fro').item()
orth_err_normalized = orth_err / (N ** 0.5)
# Bound quantities
cov_h = torch.cov(h.T)
epsilon = torch.linalg.norm(cov_h - torch.eye(N, device=h.device), 'fro').item()
trace_cov = torch.trace(cov_h).item()
L_h = ((h_prime - h) ** 2).sum(dim=1).mean().item()
delta = max(L_h - 2 * (1 - rho) * trace_cov, 0.0)
spectral_gap = 2 * rho * (1 - rho)
D_bound = delta / spectral_gap if spectral_gap > 0 else float("inf")
approx_bound = D_bound + (epsilon + D_bound) ** 2
# Procrustes
M = (h.T @ z) / len(z)
U, S, Vt = torch.linalg.svd(M)
Q = U @ Vt
procrustes_mse = ((h - z @ Q.T) ** 2).sum(dim=1).mean().item()
return {
"r2_zx": r2_zx, "r2_xz": r2_xz,
"r2_zh": r2_zh, "r2_hz": r2_hz,
"orth_err": orth_err, "orth_err_normalized": orth_err_normalized,
"epsilon": epsilon, "delta": delta, "D_bound": D_bound,
"approx_bound": approx_bound, "procrustes_mse": procrustes_mse,
"L_h": L_h, "trace_cov": trace_cov,
}
def compute_recovery_metrics(z, h, N, suffix=""):
"""R² in both directions + orthogonality. Suffix appended to keys."""
r2_zh, r2_hz = bidirectional_r2(z, h)
z1 = torch.cat([z, torch.ones(len(z), 1, device=z.device)], dim=1)
W = torch.linalg.lstsq(z1, h).solution
A = W[:N].T
orth_err = torch.linalg.norm(A.T @ A - torch.eye(N, device=A.device), 'fro').item()
return {
f"r2_zh{suffix}": r2_zh, f"r2_hz{suffix}": r2_hz,
f"orth_err{suffix}": orth_err,
f"orth_err_normalized{suffix}": orth_err / (N ** 0.5),
}
@@ -0,0 +1,56 @@
"""Nonlinear mixing functions."""
import torch
# ── 2D mixing functions ──────────────────────────────────────────────────────
def mix_spiral(z):
"""g(z) = R(π‖z‖) z — measure-preserving spiral diffeomorphism."""
norms = z.norm(dim=-1) * torch.pi
c, s = norms.cos(), norms.sin()
R = torch.stack([torch.stack([c, -s], dim=-1),
torch.stack([s, c], dim=-1)], dim=-2)
return (R @ z.unsqueeze(-1)).squeeze(-1)
def mix_banana(z):
"""Banana: x0 = z0, x1 = z1 + z0²."""
return torch.stack([z[..., 0], z[..., 1] + z[..., 0] ** 2], dim=-1)
def mix_sinusoid(z):
"""Sinusoidal shear: x0 = z0 + sin(1.5 z1), x1 = z1."""
return torch.stack([z[..., 0] + torch.sin(1.5 * z[..., 1]), z[..., 1]], dim=-1)
MIXINGS_2D = {
"spiral": mix_spiral,
"banana": mix_banana,
"sinusoid": mix_sinusoid,
# "nvp" handled via make_coupling_mixing(N=2, n_layers=...)
}
# ── Coupling-layer mixing (any dimension) ────────────────────────────────────
def make_coupling_mixing(N, n_layers=4, seed=1337, device="cuda"):
"""RealNVP-style coupling layers. Works for any even N (including N=2)."""
half = N // 2
torch.manual_seed(seed)
Ws = []
for _ in range(n_layers):
W, _ = torch.linalg.qr(torch.randn(half, half, device=device))
Ws.append(W * 2.0)
def mix(z):
for i, W in enumerate(Ws):
z1, z2 = z[..., :half], z[..., half:]
if i % 2 == 0:
z2 = z2 + torch.tanh(z1 @ W)
else:
z1 = z1 + torch.tanh(z2 @ W)
z = torch.cat([z1, z2], dim=-1)
return z
return mix
@@ -0,0 +1,55 @@
"""Encoder architectures."""
import torch
import torch.nn as nn
import numpy as np
def make_mlp_encoder(N, hidden=256, n_layers=4, device="cuda"):
"""MLP encoder."""
layers = [nn.Linear(N, hidden), nn.GELU()]
for _ in range(n_layers - 1):
layers += [nn.Linear(hidden, hidden), nn.GELU()]
layers.append(nn.Linear(hidden, N))
return nn.Sequential(*layers).to(device)
class MatchedEncoder(nn.Module):
"""Inverse coupling-layer encoder matched to NVP mixing architecture."""
def __init__(self, N, n_layers=4, device="cuda"):
super().__init__()
half = N // 2
self.half = half
self.n_layers = n_layers
self.Ws = nn.ParameterList([
nn.Parameter(torch.randn(half, half, device=device) / np.sqrt(half))
for _ in range(n_layers)
])
def forward(self, x):
for i, W in reversed(list(enumerate(self.Ws))):
z1, z2 = x[..., :self.half], x[..., self.half:]
if i % 2 == 0:
z2 = z2 - torch.tanh(z1 @ W)
else:
z1 = z1 - torch.tanh(z2 @ W)
x = torch.cat([z1, z2], dim=-1)
return x
def make_matched_encoder(N, n_layers=4, seed=42, device="cuda"):
torch.manual_seed(seed)
return MatchedEncoder(N, n_layers=n_layers, device=device).to(device)
def make_cnn_encoder(d_latent=2, device="cuda"):
return nn.Sequential(
nn.Conv2d(3, 32, 4, 2, 1), nn.BatchNorm2d(32), nn.GELU(),
nn.Conv2d(32, 64, 4, 2, 1), nn.BatchNorm2d(64), nn.GELU(),
nn.Conv2d(64, 128, 4, 2, 1), nn.BatchNorm2d(128), nn.GELU(),
nn.Conv2d(128, 256, 4, 2, 1), nn.BatchNorm2d(256), nn.GELU(),
torch.nn.AvgPool2d(4), nn.Flatten(),
nn.Linear(256, 256), nn.BatchNorm1d(256), nn.GELU(),
nn.Linear(256, d_latent),
).to(device)
@@ -0,0 +1,95 @@
"""DMC Reacher rendering and dataset utilities."""
import os
os.environ.setdefault("MUJOCO_GL", "egl")
import numpy as np
import torch
from dm_control import suite
from tqdm import tqdm
from .data import ou_augment
def make_env():
return suite.load(domain_name="reacher", task_name="hard")
def render_at(env, qpos, target, height=64, width=64):
"""Set joint angles and render → (3, H, W) float32 in [0, 1]."""
env.physics.data.qpos[:2] = qpos
env.physics.data.qvel[:] = 0
env.physics.named.model.geom_pos['target', :2] = target
env.physics.forward()
rgb = env.physics.render(height=height, width=width, camera_id=0)
return rgb.transpose(2, 0, 1).astype(np.float32) / 255.0
def render_batch(env, qpos_batch, target, height=64, width=64):
"""Render batch → (N, 3, H, W)."""
N = len(qpos_batch)
imgs = np.empty((N, 3, height, width), dtype=np.float32)
for i in tqdm(range(N), desc="Rendering"):
imgs[i] = render_at(env, qpos_batch[i], target, height, width)
return imgs
def generate_ou_image_pairs(env, N, rho, target, seed=9999):
"""
Sample OU latent pairs, render both → (img_t, img_tp1, z_t, z_tp1).
Uses the same OU process as the rest of the repo but renders through MuJoCo.
"""
rng = np.random.default_rng(seed)
z_t = rng.standard_normal((N, 2)).astype(np.float32)
eps = rng.standard_normal((N, 2)).astype(np.float32)
z_tp1 = rho * z_t + np.sqrt(1 - rho**2) * eps
print(f"Rendering {2 * N} images (rho={rho})...")
img_t = render_batch(env, z_t, target)
img_tp1 = render_batch(env, z_tp1, target)
return img_t, img_tp1, z_t, z_tp1
def normalize_images(img_t, img_tp1, img_eval=None):
"""Per-channel mean/std normalization. Returns normalized arrays + stats."""
mean = img_t.mean(axis=(0, 2, 3), keepdims=True)
std = img_t.std(axis=(0, 2, 3), keepdims=True) + 1e-6
img_t = (img_t - mean) / std
img_tp1 = (img_tp1 - mean) / std
if img_eval is not None:
img_eval = (img_eval - mean) / std
return img_t, img_tp1, img_eval, mean, std
return img_t, img_tp1, mean, std
def solve_ik_grid(env, target, n_grid=200):
"""Find joint angles that place fingertip at target via grid search."""
best_dist, best_qpos = np.inf, None
for q0 in np.linspace(-np.pi, np.pi, n_grid):
for q1 in np.linspace(-np.pi, np.pi, n_grid):
env.physics.data.qpos[:2] = [q0, q1]
env.physics.named.model.geom_pos['target', :2] = target
env.physics.forward()
tip = env.physics.named.data.geom_xpos['finger'][:2]
d = np.linalg.norm(tip - target)
if d < best_dist:
best_dist = d
best_qpos = np.array([q0, q1])
return best_qpos, best_dist
class ReacherOUDataset(torch.utils.data.Dataset):
"""Prerendered OU image pairs with ground-truth latents."""
def __init__(self, img_t, img_tp1, z_t, z_tp1):
self.img_t = torch.from_numpy(img_t)
self.img_tp1 = torch.from_numpy(img_tp1)
self.z_t = torch.from_numpy(z_t)
self.z_tp1 = torch.from_numpy(z_tp1)
def __len__(self):
return len(self.img_t)
def __getitem__(self, i):
return self.img_t[i], self.img_tp1[i], self.z_t[i], self.z_tp1[i]