import warnings
from sklearn.cluster import MiniBatchKMeans
from sklearn.decomposition import PCA
class DeepClusterDataset(data.Dataset):
"""Wraps CIFAR-10 (base transform=None) so the applied transform and pseudo-label targets
can be swapped out each reclustering round without touching the underlying data."""
def __init__(self, base: data.Dataset) -> None:
self.base = base
self.transform: VT.Compose | None = None
self.pseudo_labels = np.zeros(len(base), dtype=np.int64)
def __len__(self) -> int:
return len(self.base)
def __getitem__(self, index: int) -> tuple[Tensor, int]:
image, _ = self.base[index]
return self.transform(image), int(self.pseudo_labels[index])
class DeepClusterModel(nn.Module):
"""Backbone (fed Sobel-filtered input) + a classification head over pseudo-labels.
The head is reinitialized every reclustering round, since cluster identities are
arbitrary and don't carry meaning across rounds."""
def __init__(self, num_clusters: int) -> None:
super().__init__()
self.sobel = SobelFilter()
resnet = make_cifar_resnet(in_channels=2)
self.backbone = nn.Sequential(*list(resnet.children())[:-1])
self.cluster_head = nn.Linear(512, num_clusters)
def reset_head(self, num_clusters: int) -> None:
device = next(self.backbone.parameters()).device
self.cluster_head = nn.Linear(512, num_clusters).to(device)
def features(self, x: Tensor) -> Tensor:
return self.backbone(self.sobel(x))
def forward(self, x: Tensor) -> Tensor:
return self.cluster_head(self.features(x).flatten(start_dim=1))
def train_deepcluster(
model: DeepClusterModel,
dataset: DeepClusterDataset,
epochs: int,
num_clusters: int,
device: torch.device,
train_transform: VT.Compose,
cluster_transform: VT.Compose,
checkpoint_path: Path = Path("./checkpoints/deepcluster/best.pt.gz"),
resume_from: Path | None = None,
recluster_every: int = 1,
) -> tuple[list[float], list[np.ndarray]]:
"""Saves (overwriting) whenever the epoch loss improves on the best seen so far. Cross-entropy
against pseudo-labels doesn't have BYOL's collapse-via-low-loss failure mode, but it's still
only a proxy - the cluster-size histogram (below) is the real diagnostic for degenerate
solutions, not this loss. Also unconditionally saves the true final-epoch state to
"final.pt.gz" once training completes, for comparison against "best.pt.gz" (see train_byol's
docstring for why that comparison matters) - and this comparison mattered here too: see the
run log below, best.pt.gz stuck on an early-epoch fluke just like BYOL's attempt 2.
`recluster_every` controls how many epochs pass between reclustering rounds (feature
extraction + PCA + k-means + a fresh classification head). The first attempt used 1 (matches
the original DeepCluster paper's cadence). `recluster_every=5` matches the actual cadence
used in the author's own DeepCluster-based paper (Milosheski, Cerar et al., "Self-supervised
learning for clustering of wireless spectrum activity," Computer Communications 2023) - their
published code reclusters every 5 epochs rather than every 1, giving the backbone several
epochs of gradient signal per cluster assignment before labels shift again. On non-reclustering
epochs, the previous round's pseudo-labels, classification head, and optimizer are reused and
continue training rather than being reset - resetting a head that's still training on the same
labels would throw away progress for no reason.
`resume_from`, if given, restores the backbone/RNG state and continues from the saved epoch.
The classification head and its optimizer are rebuilt fresh either way, since a fresh head
every reclustering round is already this method's normal behavior (cluster identities aren't
meaningful across rounds), resumed or not - only the backbone needs to carry over.
"""
model.to(device)
# model.features(x) during reclustering (below) is called directly, bypassing forward(),
# so it needs its own compile - torch.compile(model) alone only covers model(images).
model = torch.compile(model)
model.features = torch.compile(model.features)
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
start_epoch, loss_history, best_loss = load_resume_state(resume_from, model)
cluster_size_history = []
optimizer = None
train_loader = None
for epoch in tqdm(range(start_epoch, epochs), desc="DeepCluster", initial=start_epoch, total=epochs):
if epoch % recluster_every == 0:
# 1. Extract features on an unaugmented view - clustering on noisy features would
# make cluster assignments unstable from one round to the next.
model.eval()
dataset.transform = cluster_transform
cluster_loader = data.DataLoader(
dataset, batch_size=256, shuffle=False, pin_memory=True, num_workers=args.num_workers
)
features, _ = extract_embeddings(model.features, cluster_loader, device)
# 2. PCA-whiten, then k-means for pseudo-labels.
features = PCA(n_components=256, whiten=True, random_state=0).fit_transform(features)
pseudo_labels = MiniBatchKMeans(n_clusters=num_clusters, n_init="auto", random_state=0).fit_predict(
features
)
dataset.pseudo_labels = pseudo_labels
cluster_sizes = np.bincount(pseudo_labels, minlength=num_clusters)
cluster_size_history.append(cluster_sizes)
# 3. Fresh head each reclustering round.
model.reset_head(num_clusters)
optimizer = optim.Adam(model.parameters(), lr=1e-3, fused=device.type == "cuda")
# 4. Inverse-frequency sampling, so a few large clusters don't dominate the gradient.
sample_weights = 1.0 / cluster_sizes[pseudo_labels]
sampler = data.WeightedRandomSampler(sample_weights, num_samples=len(dataset), replacement=True)
dataset.transform = train_transform
train_loader = data.DataLoader(
dataset, batch_size=256, sampler=sampler, pin_memory=True, num_workers=args.num_workers
)
model.train()
epoch_loss = 0.0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
with torch.autocast(device_type=device.type, dtype=torch.bfloat16):
loss = F.cross_entropy(model(images), labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_loss += loss.item()
epoch_loss /= len(train_loader)
loss_history.append(epoch_loss)
if epoch_loss < best_loss:
best_loss = epoch_loss
save_checkpoint(
checkpoint_path, model, optimizer, epoch + 1, loss_history=loss_history, best_loss=best_loss
)
save_checkpoint(
checkpoint_path.parent / "final.pt.gz",
model,
optimizer,
epochs,
loss_history=loss_history,
best_loss=best_loss,
)
return loss_history, cluster_size_history
# Same noisy torchvision/NumPy VisibleDeprecationWarning as the main dataset setup above.
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
deepcluster_dataset = DeepClusterDataset(
datasets.CIFAR10(root=DATA_ROOT, train=True, download=False, transform=None)
)
deepcluster_model = DeepClusterModel(num_clusters=100)
# Attempt 1 (recluster_every=1, the original paper's cadence) - see the appendix prose for the
# full story. best.pt.gz got stuck on an early-epoch fluke (same pattern as BYOL's below);
# final.pt.gz is the checkpoint that matters. Superseded by attempt 2; checkpoints not kept on disk.
# deepcluster_loss_history, deepcluster_cluster_sizes = train_deepcluster(
# deepcluster_model,
# deepcluster_dataset,
# epochs=200,
# num_clusters=100,
# device=device,
# train_transform=build_augment("light"),
# cluster_transform=build_augment("none"),
# recluster_every=1,
# )
# Attempt 2 (recluster_every=5, adopted as the default) - see the appendix prose for why. Same
# best.pt.gz-vs-final.pt.gz fluke pattern as attempt 1; final.pt.gz is again the checkpoint that
# matters.
# deepcluster_loss_history, deepcluster_cluster_sizes = train_deepcluster(
# deepcluster_model,
# deepcluster_dataset,
# epochs=200,
# num_clusters=100,
# device=device,
# train_transform=build_augment("light"),
# cluster_transform=build_augment("none"),
# recluster_every=5,
# )
deepcluster_checkpoint = load_checkpoint(Path("./checkpoints/deepcluster/final.pt.gz"), deepcluster_model)
fig, ax = plt.subplots(figsize=(WIDTH, WIDTH / GOLDEN_RATIO), dpi=200, constrained_layout=True)
ax.plot(
range(len(deepcluster_checkpoint["loss_history"])),
deepcluster_checkpoint["loss_history"],
linewidth=1.5,
alpha=0.80,
clip_on=False,
)
ax.set_xlabel("epoch", fontsize=9)
ax.set_ylabel("cross-entropy loss", fontsize=9)
ax.set_title("DeepCluster training loss", fontsize=10)
ax.tick_params(labelsize=8)
ax.autoscale(axis="x", tight=True) # epoch 0 starts flush against the y-axis, no left margin
ax.grid(color="0.8", linestyle=":", linewidth=1)
save_fig(fig, "deepcluster-loss.webp")