Source code for peal.sparse_dictionaries.activation_cache

"""Optional on-disk cache for encoder activations.

Pushing the 182k CelebA images through the semantic encoder takes ~25 minutes
and produces the same 768-d vectors every time, which makes a hyperparameter
sweep over the dictionary mostly a sweep over image decoding. Caching the
activations turns a run into ~2 minutes of dictionary fitting plus evaluation.

Opt in with ``PEAL_ACT_CACHE=1``. It is off by default on purpose: silently
reading a stale checkpoint's numbers has cost this project time before, and a
cache that is on by default is one more way for that to happen. The key below
covers the dataset identity and the encoder weights' mtime and size, so a
retrained encoder or a changed split misses rather than returns stale vectors —
but only an explicit opt-in makes the trade-off visible in the command line.
"""

import hashlib
import json
import os

import torch
from peal.log import get_logger

_log = get_logger(__name__)


def _encoder_fingerprint(generator) -> dict:
    """Whatever identifies the weights the activations came from."""
    out = {}
    base = getattr(getattr(generator, "config", None), "base_path", None)
    if base:
        for rel in ("square64_ddim/final.ckpt", "square64_ddim/last.ckpt"):
            path = os.path.join(base, rel)
            if os.path.exists(path):
                st = os.stat(path)
                out[rel] = [int(st.st_mtime), int(st.st_size)]
    cfg = getattr(generator, "config", None)
    out["model_type"] = getattr(cfg, "model_type", None)
    out["encoder_path"] = getattr(cfg, "encoder_path", None)
    # Two runs over the same dataset with different encoders must not share a
    # cache entry. set_encoder copies `encoder` into model_type, but keying on
    # both means the distinction does not depend on that happening first.
    out["encoder"] = getattr(cfg, "encoder", None)
    out["encoder_input_space"] = getattr(cfg, "encoder_input_space", None)
    out["encoder_dimensions"] = getattr(
        getattr(generator, "config", None), "encoder_dimensions", None
    )
    return out


[docs] def cache_path(generator, data_config) -> str: """Path of the cache file for this generator/dataset combination. The file name is ``acts_<sha1[:16]>.pt`` under ``<generator.config.base_path>/activation_cache``; the digest covers the encoder fingerprint and every ``data_config`` field that changes which images are encoded or how they are preprocessed. Parameters ---------- generator : InvertibleGenerator Generator whose encoder produces the activations; only its ``config`` is inspected. data_config : DataConfig Dataset configuration; missing fields count as ``None`` in the key. Returns ------- str Absolute or base-path-relative path of the ``.pt`` cache file. """ key = { "encoder": _encoder_fingerprint(generator), "dataset_class": getattr(data_config, "dataset_class", None), "dataset_path": getattr(data_config, "dataset_path", None), "label_rel_path": getattr(data_config, "label_rel_path", None), "num_samples": getattr(data_config, "num_samples", None), "split": getattr(data_config, "split", None), "input_size": getattr(data_config, "input_size", None), "normalization": getattr(data_config, "normalization", None), "x_selection": getattr(data_config, "x_selection", None), "set_negative_to_zero": getattr(data_config, "set_negative_to_zero", None), "crop_size": getattr(data_config, "crop_size", None), "downsize": getattr(data_config, "downsize", None), } digest = hashlib.sha1( json.dumps(key, sort_keys=True, default=str).encode() ).hexdigest()[:16] return os.path.join( generator.config.base_path, "activation_cache", f"acts_{digest}.pt" )
[docs] def enabled() -> bool: """Whether ``PEAL_ACT_CACHE`` is set to anything but ``0``/``false``/empty.""" return os.environ.get("PEAL_ACT_CACHE", "0") not in ("0", "", "false", "False")
[docs] def extract(datasets, feature_extractor, device, batch_size=256, num_workers=4): """Encode every dataset, returning one (X, Y) pair per dataset. Parameters ---------- datasets : list of torch.utils.data.Dataset Datasets to encode, each yielding ``x`` or ``(x, y, ...)`` items. feature_extractor : callable Maps a batch of inputs on ``device`` to activations ``[B, act_size]``. device : str or torch.device Device the inputs are moved to before encoding. batch_size : int DataLoader batch size. num_workers : int DataLoader worker processes. Returns ------- list of tuple ``[(X, Y), ...]`` with ``X`` a float CPU tensor ``[N, act_size]`` and ``Y`` the concatenated labels, or ``None`` when the dataset yields no label. Progress is printed every 100 batches. """ out = [] for i, dataset in enumerate(datasets): loader = torch.utils.data.DataLoader( dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True, ) xs, ys = [], [] with torch.no_grad(): for b, batch in enumerate(loader): x = batch[0] if isinstance(batch, (list, tuple)) else batch xs.append(feature_extractor(x.to(device)).detach().float().cpu()) if isinstance(batch, (list, tuple)) and len(batch) > 1: ys.append(batch[1].detach().cpu()) if b % 100 == 0: _log.info( "%s", f" [activations] split {i}: batch {b}/{len(loader)}" ) out.append((torch.cat(xs), torch.cat(ys) if ys else None)) return out
[docs] def load_or_extract(generator, datasets, data_config, feature_extractor, device, **kw): """The cached form of :func:`extract`, when PEAL_ACT_CACHE says so. With the cache disabled this is exactly :func:`extract`. Otherwise the file from :func:`cache_path` is reused when its stored dataset sizes match ``[len(d) for d in datasets]``; on a miss or size mismatch the activations are extracted, written to that file and returned. Parameters ---------- generator : InvertibleGenerator Used only to derive the cache path. datasets : list of torch.utils.data.Dataset Datasets to encode. data_config : DataConfig Used only to derive the cache path. feature_extractor : callable Encoder applied to each batch. device : str or torch.device Device for the encoder inputs. **kw Forwarded to :func:`extract` (``batch_size``, ``num_workers``). Returns ------- list of tuple Same layout as :func:`extract`. """ if not enabled(): return extract(datasets, feature_extractor, device, **kw) path = cache_path(generator, data_config) if os.path.exists(path): blob = torch.load(path, map_location="cpu", weights_only=False) if blob.get("sizes") == [len(d) for d in datasets]: _log.info("%s", f"[activations] reusing {path}") return [(x, y) for x, y in blob["data"]] _log.info( "%s", f"[activations] {path} has sizes {blob.get('sizes')}, expected " f"{[len(d) for d in datasets]} — re-extracting.", ) data = extract(datasets, feature_extractor, device, **kw) os.makedirs(os.path.dirname(path), exist_ok=True) torch.save({"sizes": [len(d) for d in datasets], "data": data}, path) _log.info("%s", f"[activations] wrote {path}") return data