Siamese CIFAR-10 Tutorial¶
This tutorial shows how DLVPM can learn a representation of one data type by
creating two independently augmented views of the same CIFAR-10 image. The two
views pass through a shared residual PyTorch encoder, and
StructuralModel(is_siamese=True) learns DLVs that align the augmented views.
The learned representation is then evaluated with a linear SVM.
The full runnable script is deep_lvpm.tutorial.tutorial_siamese.
Run it with:
python -m deep_lvpm.tutorial.tutorial_siamese
Prerequisites¶
This example is heavier than the MNIST tutorial. It uses CIFAR-10, stochastic
image augmentation, a residual convolutional encoder, and a large default batch
size. A CUDA or Apple Silicon device is useful, but the script can be forced to
CPU by changing REQUESTED_DEVICE.
1. Configure the run¶
The script keeps the main user-editable settings near the top. The device
selector accepts "auto", "cpu", "cuda", or "mps" and raises a
clear error if the requested accelerator is unavailable.
import random
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import LinearSVC
from torch.utils.data import DataLoader, Dataset
from torchvision import datasets
from deep_lvpm import regularizers as dlvpm_regularizers
from deep_lvpm.model import StructuralModel
SEED = 1337
BATCH_SIZE = 2048
EPOCHS = 100
WEIGHT_DECAY = 0
NDIMS = 1024
LEARNING_RATE = 1e-4
MAX_GRADIENT_NORM = 1
REQUESTED_DEVICE = "auto"
PRINT_AUGMENTATION_DIAGNOSTICS = True
CIFAR10_DATA_DIR = Path(__file__).resolve().parents[1] / "data"
NUM_CLASSES = 10
INPUT_SHAPE = (32, 32, 3)
def choose_torch_device(requested_device="auto"):
requested_device = str(requested_device).lower()
mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
if requested_device == "auto":
if torch.cuda.is_available():
return torch.device("cuda")
if mps_available:
return torch.device("mps")
return torch.device("cpu")
if requested_device == "cuda" and not torch.cuda.is_available():
raise RuntimeError("REQUESTED_DEVICE='cuda', but CUDA is not available.")
if requested_device == "mps" and not mps_available:
raise RuntimeError("REQUESTED_DEVICE='mps', but Apple Silicon MPS is not available.")
return torch.device(requested_device)
DEVICE = choose_torch_device(REQUESTED_DEVICE)
print(f"Using PyTorch device: {DEVICE}")
2. Load CIFAR-10 and make a validation split¶
CIFAR-10 is loaded through torchvision. The script keeps both integer
labels and one-hot labels because the integer labels are convenient for the
linear SVM evaluation at the end.
train_dataset = datasets.CIFAR10(root=str(CIFAR10_DATA_DIR), train=True, download=True)
test_dataset = datasets.CIFAR10(root=str(CIFAR10_DATA_DIR), train=False, download=True)
x_train = train_dataset.data.astype("float32") / 255.0
y_train_cat = np.asarray(train_dataset.targets, dtype="int64")
x_test = test_dataset.data.astype("float32") / 255.0
y_test_cat = np.asarray(test_dataset.targets, dtype="int64")
y_train = np.eye(NUM_CLASSES, dtype="float32")[y_train_cat]
y_test = np.eye(NUM_CLASSES, dtype="float32")[y_test_cat]
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
VAL_FRACTION = 0.1
num_train = x_train.shape[0]
indices = np.arange(num_train)
rng = np.random.default_rng(SEED)
rng.shuffle(indices)
cutoff = int(num_train * (1 - VAL_FRACTION))
x_tr, x_val = x_train[indices[:cutoff]], x_train[indices[cutoff:]]
3. Create two augmented views per image¶
The Siamese setup is self-supervised: each batch contains two different augmentations of the same underlying image. The augmentation function converts images to channel-first tensors, then applies random crops, resizing, flips, and occasional grayscale conversion.
def _to_channel_first(batch: torch.Tensor) -> torch.Tensor:
if batch.ndim == 4 and batch.shape[-1] == 3:
batch = batch.permute(0, 3, 1, 2)
return batch.contiguous()
def augment(batch: torch.Tensor, training: bool = True) -> torch.Tensor:
batch = _to_channel_first(batch.float())
if not training:
return batch
cropped_images = []
for image in batch:
top = torch.randint(0, 32 - 24 + 1, (1,)).item()
left = torch.randint(0, 32 - 24 + 1, (1,)).item()
cropped_images.append(image[:, top:top + 24, left:left + 24])
x = torch.stack(cropped_images, dim=0)
x = F.interpolate(x, size=(32, 32), mode="bilinear", align_corners=False)
flip_mask = torch.rand(x.shape[0]) < 0.5
if torch.any(flip_mask):
x[flip_mask] = torch.flip(x[flip_mask], dims=(3,))
gray_mask = torch.rand(x.shape[0], 1, 1, 1) < 0.2
grayscale_weights = torch.as_tensor(
[0.2989, 0.5870, 0.1140],
dtype=x.dtype,
device=x.device,
).view(1, 3, 1, 1)
grayscale = torch.sum(x * grayscale_weights, dim=1, keepdim=True).repeat(1, 3, 1, 1)
x = torch.where(gray_mask.to(x.device), grayscale, x)
return x
The dataset and collate function return ((view_one, view_two),) so DLVPM
sees the two augmented images as two inputs belonging to the same Siamese
measurement model.
class SiameseViewsDataset(Dataset):
def __init__(self, x, training=True):
self.x = torch.as_tensor(x, dtype=torch.float32)
self.training = bool(training)
def __len__(self):
return self.x.shape[0]
def __getitem__(self, index):
return self.x[index]
def collate_siamese_views(batch, training=True):
batch = torch.stack(batch, dim=0)
view_one = augment(batch, training=training)
view_two = augment(batch, training=training)
return ((view_one, view_two),)
def make_siamese_views_dataset(x, batch_size=256, shuffle=True, training=True):
dataset = SiameseViewsDataset(x, training=training)
return DataLoader(
dataset,
batch_size=int(batch_size),
shuffle=bool(shuffle),
drop_last=bool(training),
collate_fn=lambda batch: collate_siamese_views(batch, training=training),
)
train_ds = make_siamese_views_dataset(x_tr, batch_size=BATCH_SIZE, shuffle=False, training=True)
val_ds = make_siamese_views_dataset(x_val, batch_size=BATCH_SIZE, shuffle=False, training=True)
5. Build and train the Siamese StructuralModel¶
is_siamese=True tells StructuralModel to wrap the first encoder once
and reuse it for both views. The path matrix connects view 1 to view 2 and
view 2 to view 1, because both views are augmented versions of the same image.
CIFAR_image_model = CIFARImageModel()
model_list = [CIFAR_image_model, CIFAR_image_model]
adjacency = np.array([[0, 1], [1, 0]], dtype="float32")
regularizers = [
dlvpm_regularizers.l2(WEIGHT_DECAY),
dlvpm_regularizers.l2(WEIGHT_DECAY),
]
dlvpm_model = StructuralModel(
adjacency,
model_list,
regularizers,
x_train.shape[0],
NDIMS,
orthogonalization="zca",
train_DLV=True,
is_siamese=True,
diag_offset=1e-12,
device=DEVICE,
)
The script uses a small subclass of Adam to apply Keras-style gradient
clipnorm inside optimizer.step. The same optimizer object is supplied
for both views because the weights are shared.
class ClippedAdam(torch.optim.Adam):
def __init__(self, params, clipnorm=None, **kwargs):
super().__init__(params, **kwargs)
self.clipnorm = clipnorm
def step(self, closure=None):
if self.clipnorm is not None:
parameters_with_grad = []
seen_parameter_ids = set()
for param_group in self.param_groups:
for parameter in param_group["params"]:
if parameter.grad is None or id(parameter) in seen_parameter_ids:
continue
parameters_with_grad.append(parameter)
seen_parameter_ids.add(id(parameter))
if parameters_with_grad:
nn.utils.clip_grad_norm_(parameters_with_grad, max_norm=float(self.clipnorm))
return super().step(closure=closure)
shared_optimizer = ClippedAdam(
dlvpm_model.model_list[0].parameters(),
lr=LEARNING_RATE,
eps=1e-7,
clipnorm=MAX_GRADIENT_NORM,
)
optimizers = [shared_optimizer, shared_optimizer]
dlvpm_model.compile(optimizers)
dlvpm_model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS, verbose=True)
6. Export features for linear evaluation¶
After training, the tutorial exports two feature sets: final DLVPM factors and average-pooled convolutional features. This tests whether the DLVPM projection and the convolutional base are useful for downstream classification.
def predict_view_model(view_model: nn.Module, x, batch_size=32) -> np.ndarray:
view_model.eval()
x_tensor = _to_channel_first(torch.as_tensor(x, dtype=torch.float32))
loader = DataLoader(x_tensor, batch_size=batch_size, shuffle=False)
outputs = []
with torch.no_grad():
for batch in loader:
batch = batch.to(dlvpm_model.device)
outputs.append(view_model(batch).detach().cpu())
return torch.cat(outputs, dim=0).numpy()
def predict_average_pool_features(view_model: nn.Module, x, batch_size=32) -> np.ndarray:
view_model.eval()
encoder = getattr(view_model, "encoder", view_model)
if not hasattr(encoder, "extract_average_pool_features"):
raise AttributeError("The image encoder does not expose extract_average_pool_features().")
x_tensor = _to_channel_first(torch.as_tensor(x, dtype=torch.float32))
loader = DataLoader(x_tensor, batch_size=batch_size, shuffle=False)
outputs = []
with torch.no_grad():
for batch in loader:
batch = batch.to(dlvpm_model.device)
features = encoder.extract_average_pool_features(batch)
outputs.append(features.detach().cpu())
return torch.cat(outputs, dim=0).numpy()
7. Run a linear SVM probe¶
The linear probe standardizes each feature set and fits a LinearSVC on the
CIFAR-10 training labels. This is not part of DLVPM training; it is a
downstream check of the learned representation.
def evaluate_linear_svm(feature_name, train_features, test_features):
print(f"\nLinear SVM evaluation on {feature_name}")
print(f"Train feature shape: {train_features.shape}")
print(f"Test feature shape: {test_features.shape}")
svm_clf = Pipeline(
[
("scaler", StandardScaler(with_mean=True)),
("svm", LinearSVC(C=1.0, max_iter=10000, random_state=42)),
]
)
svm_clf.fit(train_features, y_train_cat)
predictions = svm_clf.predict(test_features)
accuracy = accuracy_score(y_test_cat, predictions)
print(f"SVM accuracy on CIFAR-10 test set: {accuracy:.4f}\n")
print(classification_report(y_test_cat, predictions, digits=4))
print(confusion_matrix(y_test_cat, predictions))
return {"accuracy": accuracy, "predictions": predictions}
image_model = dlvpm_model.model_list[0]
train_dlvs = predict_view_model(image_model, x_train, batch_size=32)
test_dlvs = predict_view_model(image_model, x_test, batch_size=32)
train_average_pool_features = predict_average_pool_features(image_model, x_train, batch_size=32)
test_average_pool_features = predict_average_pool_features(image_model, x_test, batch_size=32)
test_ds = make_siamese_views_dataset(x_test, batch_size=BATCH_SIZE, shuffle=False, training=True)
test_metrics = dlvpm_model.evaluate(test_ds)
print("Test DLVPM metrics:", test_metrics)
linear_evaluation_results = {}
linear_evaluation_results["final_dlvpm_factors"] = evaluate_linear_svm(
"final DLVPM factors",
train_dlvs,
test_dlvs,
)
linear_evaluation_results["average_pool_features"] = evaluate_linear_svm(
"last average pooling layer features",
train_average_pool_features,
test_average_pool_features,
)
Summary¶
The Siamese tutorial uses DLVPM as a self-supervised representation learner:
build two augmented views of each image, share one PyTorch encoder across both
views, train the shared encoder through StructuralModel, then evaluate the
learned representation with a simple linear classifier.