Download core/model/fly_cl.py from RL-MIND/LibContinual: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/RL-MIND/LibContinual/resolve/main/core/model/fly_cl.py
- Command line
-
hf download hf://RL-MIND/LibContinual/core/model/fly_cl.py
-
curl -L -o fly_cl.py https://huggingface.co/RL-MIND/LibContinual/resolve/main/core/model/fly_cl.py
10.2 kB
| import time | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from tqdm import tqdm | |
| class FlyCL(nn.Module): | |
| def __init__(self, backbone, device, **kwargs): | |
| super().__init__() | |
| self.backbone = backbone | |
| self.device = device | |
| self.embedding_dim = int(kwargs.get("embedding_dim", backbone.feat_dim)) | |
| self.expand_dim = int(kwargs.get("expand_dim", 10000)) | |
| self.synaptic_degree = int(kwargs.get("synaptic_degree", 300)) | |
| self.coding_level = float(kwargs.get("coding_level", 0.3)) | |
| self.ridge_lower = float(kwargs.get("ridge_lower", 6)) | |
| self.ridge_upper = float(kwargs.get("ridge_upper", 10)) | |
| self.gcv_backend = kwargs.get("gcv_backend", "dual_eigh") | |
| self.disable_cudnn = bool(kwargs.get("disable_cudnn", False)) | |
| self.use_projection = bool(kwargs.get("use_projection", True)) | |
| self.use_ridge = bool(kwargs.get("use_ridge", True)) | |
| self.total_cls_num = int(kwargs["total_cls_num"]) | |
| self.init_cls_num = int(kwargs["init_cls_num"]) | |
| self.inc_cls_num = int(kwargs["inc_cls_num"]) | |
| self.mask_unseen_classes = bool(kwargs.get("mask_unseen_classes", False)) | |
| if self.embedding_dim != int(backbone.feat_dim): | |
| raise ValueError( | |
| f"embedding_dim={self.embedding_dim} does not match " | |
| f"backbone.feat_dim={backbone.feat_dim}" | |
| ) | |
| if ( | |
| self.use_projection | |
| and not 1 <= self.synaptic_degree <= self.embedding_dim | |
| ): | |
| raise ValueError("synaptic_degree must be in [1, embedding_dim]") | |
| if self.use_projection and not 0.0 < self.coding_level <= 1.0: | |
| raise ValueError("coding_level must be in (0, 1]") | |
| if self.ridge_upper <= self.ridge_lower: | |
| raise ValueError("ridge_upper must be greater than ridge_lower") | |
| if self.gcv_backend not in {"dual_eigh", "svd"}: | |
| raise ValueError("gcv_backend must be 'dual_eigh' or 'svd'") | |
| for parameter in self.backbone.parameters(): | |
| parameter.requires_grad = False | |
| self.backbone.eval() | |
| if self.disable_cudnn: | |
| torch.backends.cudnn.deterministic = True | |
| torch.backends.cudnn.enabled = False | |
| self.analytic_dim = ( | |
| self.expand_dim if self.use_projection else self.embedding_dim | |
| ) | |
| projection = self._build_sparse_projection() if self.use_projection else None | |
| self.register_buffer("projection", projection) | |
| self.register_buffer("Q", torch.zeros(self.analytic_dim, self.total_cls_num)) | |
| gram = ( | |
| torch.zeros(self.analytic_dim, self.analytic_dim) | |
| if self.use_ridge | |
| else None | |
| ) | |
| self.register_buffer("G", gram) | |
| self.register_buffer( | |
| "classifier_weight", torch.zeros(self.analytic_dim, self.total_cls_num) | |
| ) | |
| self.register_buffer("class_counts", torch.zeros(self.total_cls_num)) | |
| self._optimizer_anchor = nn.Parameter(torch.zeros(())) | |
| self._classes_seen_so_far = 0 | |
| self._is_fitted = False | |
| self.last_ridge = None | |
| self.last_feature_time = 0.0 | |
| self.last_post_time = 0.0 | |
| def _build_sparse_projection(self): | |
| """Create the reference implementation's sparse Gaussian projection.""" | |
| projection = torch.zeros(self.expand_dim, self.embedding_dim) | |
| for row in range(self.expand_dim): | |
| selected = torch.randperm(self.embedding_dim)[: self.synaptic_degree] | |
| projection[row, selected] = torch.randn(self.synaptic_degree) | |
| return projection.to_sparse_csc() | |
| def train(self, mode=True): | |
| # Trainer calls model.train(); the pre-trained feature extractor must | |
| # nevertheless stay deterministic and frozen, as in the official code. | |
| super().train(mode) | |
| self.backbone.eval() | |
| return self | |
| def before_task(self, task_idx, buffer, train_loader, test_loaders): | |
| increment = self.init_cls_num if task_idx == 0 else self.inc_cls_num | |
| self._classes_seen_so_far += increment | |
| if self._classes_seen_so_far > self.total_cls_num: | |
| raise ValueError("Seen classes exceed total_cls_num") | |
| print( | |
| f"[FlyCL] Task {task_idx}: frozen feature extraction; " | |
| "analytic classifier will be updated after the task." | |
| ) | |
| def observe(self, data): | |
| loss = self._optimizer_anchor * 0.0 | |
| return None, 0.0, loss | |
| def _extract_features(self, data_loader): | |
| features = [] | |
| labels = [] | |
| self.backbone.eval() | |
| for batch in tqdm(data_loader, desc="Fly-CL feature extraction"): | |
| images = batch["image"].to(self.device, non_blocking=True) | |
| feature = self.backbone(images) | |
| features.append(feature) | |
| labels.append(batch["label"].to(self.device, non_blocking=True)) | |
| if not features: | |
| raise ValueError("Cannot fit Fly-CL on an empty dataloader") | |
| return torch.cat(features, dim=0), torch.cat(labels, dim=0) | |
| def _fly_encode(self, features): | |
| if not self.use_projection: | |
| return features.T | |
| expanded = torch.sparse.mm(self.projection, features.T) | |
| topk = max(1, int(self.expand_dim * self.coding_level)) | |
| values, indices = expanded.topk(topk, dim=0, largest=True) | |
| encoded = torch.zeros_like(expanded) | |
| encoded.scatter_(0, indices, values) | |
| return encoded | |
| def _select_ridge_parameter(self, features, targets): | |
| """Generalized cross-validation used by the official implementation.""" | |
| if self.gcv_backend == "svd": | |
| u, singular_values, _ = torch.linalg.svd(features, full_matrices=False) | |
| singular_sq = singular_values.square() | |
| else: | |
| sample_gram = features @ features.T | |
| singular_sq, u = torch.linalg.eigh(sample_gram) | |
| singular_sq.clamp_(min=0.0) | |
| uty = u.T @ targets | |
| exponents = np.arange(self.ridge_lower, self.ridge_upper) | |
| ridges = torch.as_tensor( | |
| 10.0 ** exponents, | |
| dtype=features.dtype, | |
| device=features.device, | |
| ) | |
| n_samples = features.shape[0] | |
| scores = [] | |
| for ridge in ridges: | |
| diagonal = singular_sq / (singular_sq + ridge) | |
| degrees_of_freedom = diagonal.sum() | |
| prediction = u @ (diagonal[:, None] * uty) | |
| residual = torch.linalg.vector_norm(targets - prediction).square() | |
| denominator = (1.0 - degrees_of_freedom / n_samples).square() | |
| scores.append((residual / n_samples) / denominator) | |
| return ridges[torch.argmin(torch.stack(scores))] | |
| def fit_features(self, features, labels): | |
| """Update the analytic head from already-extracted backbone features.""" | |
| encoded = self._fly_encode(features) | |
| del features | |
| targets = F.one_hot(labels.long(), self.total_cls_num).to(encoded.dtype) | |
| self.class_counts.add_(targets.sum(dim=0)) | |
| del labels | |
| self.Q.add_(encoded @ targets) | |
| if self.use_ridge: | |
| self.G.add_(encoded @ encoded.T) | |
| ridge = self._select_ridge_parameter(encoded.T, targets) | |
| del encoded, targets | |
| regularized = self.G.clone() | |
| regularized.diagonal().add_(ridge) | |
| cholesky = torch.linalg.cholesky(regularized) | |
| del regularized | |
| self.classifier_weight.copy_(torch.cholesky_solve(self.Q, cholesky)) | |
| self.last_ridge = float(ridge.item()) | |
| else: | |
| del encoded, targets | |
| counts = self.class_counts.clamp_min(1.0).unsqueeze(0) | |
| self.classifier_weight.copy_(self.Q / counts) | |
| self.last_ridge = None | |
| self._is_fitted = True | |
| def after_task(self, task_idx, buffer, train_loader, test_loaders): | |
| total_start = time.perf_counter() | |
| feature_start = time.perf_counter() | |
| features, labels = self._extract_features(train_loader) | |
| self.last_feature_time = time.perf_counter() - feature_start | |
| self.fit_features(features, labels) | |
| self.last_post_time = ( | |
| time.perf_counter() - total_start - self.last_feature_time | |
| ) | |
| print( | |
| f"[FlyCL] Task {task_idx} fitted: " | |
| f"ridge={self.last_ridge if self.last_ridge is not None else 'disabled'}, " | |
| f"feature_time={self.last_feature_time:.2f}s, " | |
| f"post_time={self.last_post_time:.2f}s" | |
| ) | |
| def predict_features(self, features, targets=None): | |
| """Predict from cached backbone features using the fitted analytic head.""" | |
| if not self._is_fitted: | |
| raise RuntimeError("Fly-CL classifier is not fitted; call after_task first") | |
| encoded = self._fly_encode(features) | |
| if self.use_ridge: | |
| if self.use_projection: | |
| logits = torch.sparse.mm( | |
| encoded.T.to_sparse_csc(), self.classifier_weight | |
| ) | |
| else: | |
| logits = encoded.T @ self.classifier_weight | |
| else: | |
| encoded = F.normalize(encoded, dim=0) | |
| prototypes = F.normalize(self.classifier_weight, dim=0) | |
| if self.use_projection: | |
| logits = torch.sparse.mm(encoded.T.to_sparse_csc(), prototypes) | |
| else: | |
| logits = encoded.T @ prototypes | |
| if self.mask_unseen_classes: | |
| logits = logits[:, : self._classes_seen_so_far] | |
| accuracy = None | |
| if targets is not None: | |
| predictions = logits.argmax(dim=1).cpu() | |
| accuracy = predictions.eq(targets.cpu()).float().mean().item() | |
| return logits, accuracy | |
| def inference(self, data): | |
| images = data["image"].to(self.device, non_blocking=True) | |
| features = self.backbone(images) | |
| return self.predict_features(features, data["label"]) | |
| def get_parameters(self, config): | |
| return [self._optimizer_anchor] | |