diff --git a/.gitignore b/.gitignore index 68bc17f..b62588b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +data + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -158,3 +160,16 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + + +erasers_cache +lightning_logs +wandb +images/ + +probe-ckpts/ +*.bsh +wandb* + +24-11-21 +24-11-21-seeds diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 064824f..3bd8bf7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,3 @@ repos: hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] -- repo: https://github.com/codespell-project/codespell - rev: v2.2.4 - hooks: - - id: codespell diff --git a/experiments/cli.py b/experiments/cli.py new file mode 100644 index 0000000..ffc5109 --- /dev/null +++ b/experiments/cli.py @@ -0,0 +1,705 @@ +from pathlib import Path +from typing import TypeVar, Type, Any, cast, Literal +from dataclasses import dataclass +from simple_parsing import ArgumentParser +import lovely_tensors as lt +import os +import pickle +import json + +import wandb +import torch +import torch.nn.functional as F +import torchvision.transforms.v2 as transforms +import torchvision.utils as vutils +from torch import Tensor +from torchvision.datasets import CIFAR10 +from torchvision.transforms.v2.functional import to_dtype, to_image +from datasets import load_dataset, DatasetDict, load_from_disk +from mup import make_base_shapes +from concept_erasure.quadratic import QuadraticFitter +from concept_erasure.leace import LeaceFitter +from concept_erasure.alf_qleace import AlfQLeaceFitter +from concept_erasure.re import RandomEraser + +from mdl.lenet_probe import LeNetProbe +from mdl.mlp_probe import ResMlpProbe, MlpProbe, LinearProbe +from mdl.sweep import Sweep +from mdl.vision_probe_old import ConvNextProbe, SwinProbe +from mdl.resnet_probe import ResNetProbe + +torch.set_float32_matmul_precision('high') + +@dataclass +class Args: + # General settings + name: str = "" + out: str = "results" + + # Dataset options + dataset: Literal["cifar10", "cifarnet", "fake-cifar10", + "fake-cifarnet", "svhn", "fake-svhn", "fake-leace-cifar10", + "fake-leace-cifarnet", "fake-leace-svhn", + ] = "cifar10" + eraser: Literal["control", "leace", "oleace", "qleace", "alf_qleace", "random"] = "control" + method: Literal["leace", "orth", "none"] = "leace" + shrinkage: bool = False + normalize: bool = False + post_erase_normalize: bool = False + alf_qleace_target: float = 0.9 + + # Model architecture + net: Literal["mlp", "resmlp", "resnet", "convnext", "linear", "vision", "swin", "lenet"] = ( + "mlp" + ) + act: Literal["relu", "gelu", "swiglu"] = "relu" + + # Model dimensions for simple models + width: int = 128 + depth: int = 2 + mup_width: int | None = None # Width of the base model used to tune the initial LR + mup_depth: int | None = None # Depth of the base model used to tune the initial LR + + # Model dimensions for SOTA vision architectures + # arch: Literal["atto", "femto", "pico", "nano", "tiny"] = "atto" + # mup_arch: Literal["atto", "femto", "pico", "nano", "tiny"] = "atto" + + # Training parameters + lr: float = 1e-3 + b1: float = 0.9 + num_seeds: int = 5 + max_epochs: int = 30_000 + early_stop_epochs: int = 100 + + # Runtime flags + debug: bool = False + nocache: bool = False + nowritecache: bool = False + save: bool = False + overwrite: bool = False + trial: bool = False # Run a single trial with all data + + +T = TypeVar("T") + + +def assert_type(typ: Type[T], obj: Any) -> T: + """Assert that an object is of a given type at runtime and return it.""" + if not isinstance(obj, typ): + raise TypeError(f"Expected {typ.__name__}, got {type(obj).__name__}") + + return cast(typ, obj) + + +def get_cifarnet(shuffle=True): + cache_dir = 'data/cache' + os.makedirs(cache_dir, exist_ok=True) + cache_path = os.path.join(cache_dir, f"cifar_processed{'_unshuffled' if not shuffle else ''}.pkl") + if os.path.exists(cache_path): + with open(cache_path, "rb") as f: + return pickle.load(f) + + + def map_fn(ex): + return { + "input_ids": to_dtype(to_image(ex["img"]), dtype=torch.float32, scale=True), + "label": ex["label"] + } + + data = assert_type(DatasetDict, load_dataset("EleutherAI/cifarnet")) + + nontest = data["train"].map(function=map_fn) + nontest.set_format(type="torch", columns=["input_ids", "label"]) + + X = assert_type(Tensor, nontest["input_ids"]) + Y = assert_type(Tensor, nontest["label"]) + + if shuffle: + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm], Y[perm] + + # Get number of classes + k = int(Y.max()) + 1 + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + + with open(cache_path, "wb") as f: + pickle.dump((X_train, Y_train, X_val, Y_val, k, X, Y), f) + + return X_train, Y_train, X_val, Y_val, k, X, Y + + +def get_cifar10(device: str | torch.device = 'cuda', shuffle=True): + nontest = CIFAR10("data/cache/cifar10", download=True) + images, labels = zip(*nontest) + + + X = torch.stack([ + to_dtype(to_image(item), dtype=torch.float32, scale=True) + for item in images + ]).to(device) + + Y = torch.tensor(labels).to(device) + + # Shuffle deterministically + if shuffle: + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm], Y[perm] + + k = int(Y.max()) + 1 + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + + return X_train, Y_train, X_val, Y_val, k, X, Y + + +def get_fake_leace_cifar10(shuffle=True): + train = load_from_disk("data/leace-and-quadratic-iterative-erasure-cifar10/train") + val = load_from_disk("data/leace-and-quadratic-iterative-erasure-cifar10/val") + train.set_format(type="torch", columns=["image", "label"]) + val.set_format(type="torch", columns=["image", "label"]) + + X_train = train["image"] + Y_train = train["label"] + X_val = val["image"] + Y_val = val["label"] + + X = X_train + Y = Y_train + k = int(Y_train.max()) + 1 + + if shuffle: + rng = torch.Generator(device=X_train.device).manual_seed(42) + perm = torch.randperm(len(X_train), generator=rng, device=X_train.device) + X_train, Y_train = X_train[perm], Y_train[perm] + perm = torch.randperm(len(X_val), generator=rng, device=X_val.device) + X_val, Y_val = X_val[perm], Y_val[perm] + + return X_train, Y_train, X_val, Y_val, k, X, Y + +def get_fake_leace_cifarnet(shuffle=True): + train = load_from_disk("data/leace-and-quadratic-iterative-erasure-cifarnet/train") + val = load_from_disk("data/leace-and-quadratic-iterative-erasure-cifarnet/val") + train.set_format(type="torch", columns=["image", "label"]) + val.set_format(type="torch", columns=["image", "label"]) + + X_train = train["image"] + Y_train = train["label"] + X_val = val["image"] + Y_val = val["label"] + + X = X_train + Y = Y_train + k = int(Y_train.max()) + 1 + + if shuffle: + rng = torch.Generator(device=X_train.device).manual_seed(42) + perm = torch.randperm(len(X_train), generator=rng, device=X_train.device) + X_train, Y_train = X_train[perm], Y_train[perm] + perm = torch.randperm(len(X_val), generator=rng, device=X_val.device) + X_val, Y_val = X_val[perm], Y_val[perm] + + return X_train, Y_train, X_val, Y_val, k, X, Y + +def get_fake_leace_svhn(shuffle=True): + train = load_from_disk("data/leace-and-quadratic-iterative-erasure-svhn/train") + val = load_from_disk("data/leace-and-quadratic-iterative-erasure-svhn/val") + train.set_format(type="torch", columns=["image", "label"]) + val.set_format(type="torch", columns=["image", "label"]) + + X_train = train["image"] + Y_train = train["label"] + X_val = val["image"] + Y_val = val["label"] + + X = X_train + Y = Y_train + k = int(Y_train.max()) + 1 + + if shuffle: + rng = torch.Generator(device=X_train.device).manual_seed(42) + perm = torch.randperm(len(X_train), generator=rng, device=X_train.device) + X_train, Y_train = X_train[perm], Y_train[perm] + perm = torch.randperm(len(X_val), generator=rng, device=X_val.device) + X_val, Y_val = X_val[perm], Y_val[perm] + + return X_train, Y_train, X_val, Y_val, k, X, Y + + + +def get_fake_cifarnet(shuffle=True): + train = load_dataset("EleutherAI/erased-cifarnet", split="train") + X = torch.stack([to_dtype(to_image(img), dtype=torch.float32, scale=True) for img in train["image"]]) # type: ignore + Y = torch.tensor(train["label"]) + + if shuffle: + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm], Y[perm] + + k = int(Y.max()) + 1 + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + + return X_train, Y_train, X_val, Y_val, k, X, Y + + +def get_fake_cifar10(shuffle=True): + train = load_dataset("EleutherAI/erased-cifar10", split="train") + X = torch.stack([to_dtype(to_image(img), dtype=torch.float32, scale=True) for img in train["image"]]) + Y = torch.tensor(train["label"]) + + if shuffle: + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm], Y[perm] + + k = int(Y.max()) + 1 + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + + return X_train, Y_train, X_val, Y_val, k, X, Y + + +def get_svhn(device, shuffle=True): + data = load_dataset("ufldl-stanford/svhn", 'cropped_digits', split='train') + X = torch.stack([to_dtype(to_image(img), dtype=torch.float32, scale=True) for img in data["image"]]) + Y = torch.tensor(data["label"]) + + if shuffle: + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm], Y[perm] + + k = int(Y.max()) + 1 + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + + return X_train, Y_train, X_val, Y_val, k, X, Y + + +def get_fake_svhn(shuffle=True): + data = load_dataset("EleutherAI/erased-svhn", split="train") + X = torch.stack([to_dtype(to_image(img), dtype=torch.float32, scale=True) for img in data["image"]]) + Y = torch.tensor(data["label"]) + + if shuffle: + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm], Y[perm] + + k = int(Y.max()) + 1 + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + + return X_train, Y_train, X_val, Y_val, k, X, Y + + +def normalize_dataset( + X: Tensor, X_train: Tensor, X_val: Tensor +) -> tuple[Tensor, Tensor, Tensor]: + eps = torch.finfo(X_train.dtype).eps + X_flat = X_train.reshape(X_train.shape[0], -1) + + mean = X_flat.mean(dim=0, keepdim=True) + scaling = torch.std(X_flat, dim=0) + eps + + def normalize_data(data: Tensor) -> Tensor: + data_flat = data.reshape(data.shape[0], -1) + data_centered = data_flat - mean + data_normalized = data_centered / scaling + return data_normalized.reshape(data.shape) + + X = normalize_data(X) + X_train = normalize_data(X_train) + X_val = normalize_data(X_val) + + return X, X_train, X_val + + +class IdentityEraser: + def __init__(self): + pass + + def __call__(self, x: Tensor) -> Tensor: + return x + + def to(self, device: str | torch.device) -> "IdentityEraser": + return self + + +def get_cache_key(dataset_str, eraser_str, dtype, method, shrinkage, alf_qleace_target, random_erase_dims): + if eraser_str == 'alf_qleace': + return f"{eraser_str}_{dataset_str}_{dtype}_{method}_{shrinkage}_{alf_qleace_target}" + elif eraser_str == 'leace': + return f"{eraser_str}_{dataset_str}_{dtype}_{method}_{shrinkage}" + elif eraser_str == 'qleace': + return f"{eraser_str}_{dataset_str}_{dtype}" + elif eraser_str == 'control': + return f"{eraser_str}" + elif eraser_str == 'random': + return f"{eraser_str}_{dataset_str}_{random_erase_dims}" + else: + raise ValueError(f"Unknown eraser: {eraser_str}") + + +def load_eraser( + eraser_str: str, + dataset_str: str, + dtype: torch.dtype, + method: str, + shrinkage: bool, + alf_qleace_target: float | None, + X_train: Tensor, + Y_train: Tensor, + num_features: int, + k: int, + nowritecache: bool, + nocache: bool = False, + device: str | torch.device = "cpu", + fit_device: str | torch.device = "cpu", + random_erase_dims=300 +): + state_path = Path("erasers_cache") / "state.pth" + state_path.parent.mkdir(parents=True, exist_ok=True) + state = {} if not state_path.exists() else torch.load(state_path, weights_only=False) + + cache_key = get_cache_key(dataset_str, eraser_str, dtype, method, shrinkage, alf_qleace_target, random_erase_dims) + + if cache_key not in state or nocache: + if eraser_str == "control": + state[cache_key] = IdentityEraser() + elif eraser_str == "random": + state[cache_key] = RandomEraser(X_train.flatten(1).shape[1], erase_dims=random_erase_dims) + else: + if eraser_str == "leace": + fitter = LeaceFitter(num_features, k, dtype=dtype, device=device, method=method, shrinkage=shrinkage) + elif eraser_str == "alf_qleace": + fitter = AlfQLeaceFitter(num_features, k, dtype=dtype, device=device, method=method, shrinkage=shrinkage, target_erasure=alf_qleace_target) + else: + fitter = QuadraticFitter(num_features, k, dtype=dtype, device=device) + + Y_tensor = ( + F.one_hot(Y_train, k) + if eraser_str != "qleace" + else Y_train + ).to(device) + X_tensor = X_train.flatten(1).to(device).to(dtype) + fitter.update(X_tensor, Y_tensor) + fitter = fitter.to(fit_device) + + state[cache_key] = fitter.eraser + + if not nowritecache: + torch.save(state, state_path) + + return state[cache_key] + + +if __name__ == "__main__": + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + lt.monkey_patch() + Path("data").mkdir(exist_ok=True) + dtype = torch.bfloat16 + + parser = ArgumentParser() + parser.add_arguments(Args, dest="args") + args = parser.parse_args().args + + # Initialize directories + mup_path = Path("data/mup") + mup_path.mkdir(exist_ok=True) + + data_path = Path( + f"{args.out}" + if not args.debug + else f"debug-{args.out}" + ) + data_path.mkdir(exist_ok=True, parents=True) + + seed_path = Path(f"{args.out}-seeds") + seed_path.mkdir(exist_ok=True, parents=True) + + # Get dataset + (X_train, Y_train, X_val, Y_val, k, X, Y) = { + "cifar10": get_cifar10, + "cifarnet": get_cifarnet, + # "fake-cifar10": get_fake_cifar10, + # "fake-cifarnet": get_fake_cifarnet, + "svhn": get_svhn, + # "fake-svhn": get_fake_svhn, + "fake-leace-cifar10": get_fake_leace_cifar10, + "fake-leace-cifarnet": get_fake_leace_cifarnet, + "fake-leace-svhn": get_fake_leace_svhn, + }[args.dataset]() + X_train = X_train.to(dtype) + X_val = X_val.to(dtype) + X = X.to(dtype) + + if args.normalize: + assert args.eraser == "control" + X, X_train, X_val, = normalize_dataset(X, X_train, X_val) + + num_features = X.shape[1] * X.shape[2] * X.shape[3] + + # Get eraser + eraser = load_eraser( + args.eraser, + args.dataset, + torch.float32 if args.eraser != "leace" else torch.float64, + args.method, + args.shrinkage, + args.alf_qleace_target, + X_train, + Y_train, + num_features, + k, + args.nowritecache, + args.nocache, + "cpu", + device if args.dataset != "cifarnet" else "cpu", + ).to(device) + + # Get model + image_size = X.shape[-1] + + model_cls = { + "mlp": MlpProbe, + "resmlp": ResMlpProbe, + "resnet": ResNetProbe, + "convnext": ConvNextProbe, + "linear": LinearProbe, + "swin": SwinProbe, + "lenet": LeNetProbe, + }[args.net] + + probe_kwargs = {} + if args.net == "lenet": + with open(f'data/lenet_configs_{image_size}.json', 'r') as f: + lenet_params = json.load(f)[f"{args.depth}_{args.width}"] + + probe_kwargs['conv_hidden_sizes'] = lenet_params['conv_hidden_sizes'] + probe_kwargs['fc_hidden_sizes'] = lenet_params['fc_hidden_sizes'] + + # Prepare hyperparameter scaling factors and base shapes + base_model = model_cls( + num_classes=k, + num_features=num_features, + num_layers=args.depth, # mup depth unsupported + hidden_size=args.mup_width if args.mup_width else args.width, + # arch=args.mup_arch if args.mup_arch else args.arch, + **probe_kwargs + ) + delta_model = model_cls( + num_classes=k, + num_features=num_features, + num_layers=args.depth, + hidden_size=args.width, + # arch=args.arch, + **probe_kwargs + ) + + base_shapes_path = ( + mup_path / f"mup-{args.net}-{args.width}-{args.depth}-{args.mup_width}.bsh" + ) + make_base_shapes(base_model, delta_model, savefile=str(base_shapes_path)) + + if args.mup_depth: + if model_cls == MlpProbe: + # Depth-wise scaling for MLPs from https://arxiv.org/pdf/2305.07810 + args.lr = args.lr * (args.mup_depth / args.depth) ** (3 / 2) + else: + # More conservative scaling for vision models + args.lr = args.lr * (args.mup_depth / args.depth) ** (1 / 2) + + # Define flattening, augmentations, and eraser transform + flatten = { + "mlp": True, + "resmlp": True, + "resnet": False, + "convnext": False, + "linear": True, + "vision": False, + "swin": False, + "lenet": False, + }[args.net] + + padding = round(image_size * 0.125) + + augment = transforms.Compose( + [ + transforms.Lambda(lambda x: x.view(-1, X.shape[1], X.shape[2], X.shape[3])), + transforms.RandomCrop(image_size, padding), + transforms.RandomHorizontalFlip(), + transforms.Lambda(lambda x: x.flatten(1)), + ] + if flatten + else [ + transforms.RandomCrop(image_size, padding), + transforms.RandomHorizontalFlip(), + ] + ) + + # If LEACE, scale normalization can use the covariance of the vanilla data + # If ALF-QLEACE, scale normalization must use the covariance of the erased data + # I will gather these and hard code + if args.post_erase_normalize: + if args.eraser == "leace" or args.eraser == "control": + std = X_train.flatten(1).std(dim=0).to(device) + elif args.eraser == "alf_qleace": + std = eraser.to("cpu")(X_train.flatten(1)).std(dim=0).to(device) + else: + print("Not implemented") + else: + std = torch.tensor(1.0).to(device) + + def erase_transform(x: Tensor, y: Tensor): + x_erased = ( + eraser(x.flatten(1), y) if args.eraser == "qleace" else eraser(x.flatten(1)) + ) + + if args.post_erase_normalize: + x_erased = x_erased / std + + return x_erased if flatten else x_erased.reshape_as(x) + + if args.post_erase_normalize: + X_val = X_val.flatten(1) / X_train.flatten(1).std(dim=0).to(X_val.device) + + # Collect MDL data + # TODO this can probably be cleaned up + probe_kwargs = dict( + num_layers=args.depth, + hidden_size=args.width, + learning_rate=args.lr, + schedule_free=True, + betas=(args.b1, 0.999), + base_shapes_path=base_shapes_path, + ) + if model_cls == LeNetProbe: + probe_kwargs['conv_hidden_sizes'] = lenet_params['conv_hidden_sizes'] + probe_kwargs['fc_hidden_sizes'] = lenet_params['fc_hidden_sizes'] + if model_cls == MlpProbe: + probe_kwargs["activation"] = args.act + # if model_cls == SwinProbe or model_cls == ConvNextProbe: + # probe_kwargs["arch"] = args.arch + if args.trial: + # These are otherwise passed into the sweep + probe_kwargs["num_classes"] = k + probe_kwargs["num_features"] = num_features + probe_kwargs["dtype"] = dtype + probe_kwargs["device"] = device + + results = [] + + # size_str = f'a={args.arch}' if args.net == "convnext" or args.net == "swin" else f'h={args.width}_d={args.depth}' + size_str = f'h={args.width}_d={args.depth}' + + for seed in range(args.num_seeds): + wandb_name = f'{args.eraser} {args.name} {size_str.replace("_", " ")} s={seed} {args.net} act={args.act} lr={args.lr:.7f} b1={args.b1} n={args.normalize} es={args.early_stop_epochs} d={args.dataset}' + + seed_file = ( + seed_path + / f"{args.net}_{args.act}_{size_str}_{args.eraser}_{args.name}_{seed}_{args.dataset}.pth" + ) + if not args.overwrite and seed_file.exists(): + try: + results.append(torch.load(seed_file)) + continue + except Exception as e: + print("Caught exception: ", e) + pass + + run = ( + wandb.init( + project="mdl", + id=None, + entity="eleutherai", + name=wandb_name, + config={"eraser": args.eraser, **vars(args)}, + reinit=True, + ) + if not args.debug + else None + ) + + if args.trial: + # Run a single trial with a large dataset + model_cls(**probe_kwargs).fit( + X_train[:len(X_train)//2].to(device), + Y_train[:len(Y_train)//2].to(device), + x_val=X_val.to(device), + y_val=Y_val.to(device), + seed=0, + transform=erase_transform, + augment=augment, + max_epochs=args.max_epochs, + early_stop_epochs=args.max_epochs, + logger=run, + ) + wandb.finish() + exit(0) + + sweep = Sweep( + num_features, + k, + device=device, + dtype=dtype, + num_chunks=10, + logger=run, + probe_cls=model_cls, + ckpt_every=None, + probe_kwargs=probe_kwargs, + ) + results.append( + sweep.run( + X, + Y, + seed=seed, + transform=erase_transform, + augment=augment, + reduce_lr_on_plateau=False, + max_epochs=args.max_epochs, + early_stop_epochs=args.early_stop_epochs, + ) + ) + + if not args.debug: + try: + torch.save(results, seed_file) + except Exception as e: + print("Caught exception: ", e) + pass + + try: + wandb.finish() + except Exception as e: + print("Caught exception: ", e) + pass + + # Save results + torch.save( + results, + data_path + / f"{args.net}_{args.act}_{size_str}_{args.eraser}_{args.name}_{args.dataset}.pth", + ) diff --git a/experiments/concept_editing.ipynb b/experiments/concept_editing.ipynb new file mode 100644 index 0000000..7db6e7f --- /dev/null +++ b/experiments/concept_editing.ipynb @@ -0,0 +1,396 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "df = pd.read_json(\"../data/CIFAR_editing_results.json\")\n", + "df[\"loss_matrix_against_source\"] = df[\"loss_matrix_against_source\"].apply(np.array)\n", + "df[\"loss_matrix_against_target\"] = df[\"loss_matrix_against_target\"].apply(np.array)\n", + "df[\"top1_matrix_against_source\"] = df[\"top1_matrix_against_source\"].apply(np.array)\n", + "df[\"top1_matrix_against_target\"] = df[\"top1_matrix_against_target\"].apply(np.array)\n", + "# average over `seed`\n", + "mean_df = df.groupby([\"model\", \"editing_mode\"]).mean().reset_index()\n", + "# standard error over `seed`\n", + "scalar_df = df.drop(columns=[\"loss_matrix_against_source\", \"loss_matrix_against_target\", \"top1_matrix_against_source\", \"top1_matrix_against_target\"])\n", + "stderr_df = scalar_df.groupby([\"model\", \"editing_mode\"]).sem().reset_index()\n", + "\n", + "summary_df = pd.merge(mean_df, stderr_df, on=[\"model\", \"editing_mode\"], suffixes=(\"_mean\", \"_stderr\"))\n", + "row = summary_df.iloc[2]\n", + "\n", + "# 10 x 10 matrix of losses source -> target\n", + "loss_mat = row[\"loss_matrix_against_target\"]\n", + "# 10 x 10 matrix of accuracies source -> target\n", + "acc_mat = row[\"top1_matrix_against_target\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "summary_df.drop(columns=[\"loss_matrix_against_source\", \"loss_matrix_against_target\", \"top1_matrix_against_source\", \"top1_matrix_against_target\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Plot the loss matrix\n", + "plt.imshow(loss_mat)\n", + "plt.colorbar()\n", + "plt.xlabel(\"Target\")\n", + "plt.ylabel(\"Source\")\n", + "plt.title(f\"Loss for {row['model']} with {row['editing_mode']} editing\")\n", + "\n", + "plt.show()\n", + "\n", + "# Plot the accuracy matrix\n", + "plt.imshow(acc_mat)\n", + "plt.colorbar()\n", + "plt.xlabel(\"Target\")\n", + "plt.ylabel(\"Source\")\n", + "plt.title(f\"Accuracy for {row['model']} with {row['editing_mode']} editing\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Visual comparison to non-least squares quadratic concept editing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from torchvision.datasets import CIFAR10\n", + "from concept_erasure import QuadraticFitter\n", + "from concept_editing import get_editor, get_train_test_data\n", + "import torch\n", + "from torchvision.transforms.functional import to_tensor\n", + "\n", + "download_dir = \"/mnt/ssd-1/alexm/cifar10\"\n", + "data = CIFAR10(root=download_dir, download=True)\n", + "images, labels = zip(*data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "X = torch.stack(list(map(to_tensor, images))) # n x c x w x h" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "X_train, X_test, Y_train, Y_test = get_train_test_data(\n", + " total_size=None, test_size=1024, flatten=True\n", + " )\n", + "X_train = X_train.double().cpu()\n", + "Y_train = Y_train.cpu()\n", + "fitter = QuadraticFitter.fit(X_train, Y_train)\n", + "optimal_editor = fitter.editor()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "X_bar = X_train.mean(dim=0)\n", + "X_ctr = X_train - X_bar\n", + "cov_xx = X_ctr.T @ X_ctr / (X_ctr.shape[0] - 1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from concept_erasure.optimal_transport import psd_sqrt_rsqrt, psd_sqrt\n", + "def quadratic_edit(im: torch.Tensor, source: int, target: int, optimal=False):\n", + " orig_shape = im.shape\n", + " im = im.cpu().double().flatten()\n", + " if optimal:\n", + " return optimal_editor(im.unsqueeze(0), torch.tensor([source]), target).reshape(orig_shape)\n", + " else:\n", + " P = fitter.sigma_xx[source]\n", + " Q = fitter.sigma_xx[target]\n", + " _, inv_sqrt_P = psd_sqrt_rsqrt(P)\n", + " sqrt_Q = psd_sqrt(Q)\n", + " im_ctr = im - fitter.mean_x[source]\n", + " return (sqrt_Q @ inv_sqrt_P @ im_ctr + fitter.mean_x[target]).reshape(orig_shape)\n", + " \n", + "def quadratic_erase(im: torch.Tensor, source: int, optimal=False):\n", + " orig_shape = im.shape\n", + " im = im.cpu().double().flatten()\n", + " if optimal:\n", + " return fitter.eraser(im.unsqueeze(0), torch.tensor([source])).reshape(orig_shape)\n", + " else:\n", + " P = fitter.sigma_xx[source]\n", + " Q = cov_xx\n", + " _, inv_sqrt_P = psd_sqrt_rsqrt(P)\n", + " sqrt_Q = psd_sqrt(Q)\n", + " im_ctr = im - fitter.mean_x[source]\n", + " return (sqrt_Q @ inv_sqrt_P @ im_ctr + X_bar).reshape(orig_shape)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "idx = 2\n", + "im = X[idx]\n", + "source = labels[idx]\n", + "plt.imshow(im.numpy().transpose(1, 2, 0))\n", + "plt.title(f\"Original\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "target = 2\n", + "im_edit_suboptimal = quadratic_edit(torch.tensor(im), source, target, optimal=False)\n", + "im_edit_optimal = quadratic_edit(torch.tensor(im), source, target, optimal=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.imshow(im_edit_suboptimal.numpy().transpose(1, 2, 0))\n", + "plt.title(\"Naive quadratic edited\")\n", + "plt.show()\n", + "\n", + "plt.title(\"Q-LEACE edited\")\n", + "plt.imshow(im_edit_optimal.numpy().transpose(1, 2, 0))\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "diff = im_edit_suboptimal - im_edit_optimal\n", + "plt.imshow(diff.numpy().transpose(1, 2, 0))\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "diff.abs().mean() / im_edit_optimal.abs().mean()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "im_erased_suboptimal = quadratic_erase(torch.tensor(im), source, optimal=False)\n", + "im_erased_optimal = quadratic_erase(torch.tensor(im), source, optimal=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.imshow(im_erased_suboptimal.numpy().transpose(1, 2, 0))\n", + "plt.title(\"Naive quadratic erased\")\n", + "plt.show()\n", + "\n", + "plt.imshow(im_erased_optimal.numpy().transpose(1, 2, 0))\n", + "plt.title(\"Q-LEACE erased\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.imshow(im.numpy().transpose(1, 2, 0))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "diff_optimal = im_erased_optimal - im\n", + "diff_suboptimal = im_erased_suboptimal - im\n", + "print(diff_optimal.norm().mean() / im.norm().mean())\n", + "print(diff_suboptimal.norm().mean() / im.norm().mean())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "total_err_optimal = 0\n", + "total_err_suboptimal = 0\n", + "for idx in range(100):\n", + " im = X[idx]\n", + " source = labels[idx]\n", + "\n", + " im_erased_suboptimal = quadratic_erase(torch.tensor(im), source, optimal=False)\n", + " im_erased_optimal = quadratic_erase(torch.tensor(im), source, optimal=True)\n", + "\n", + " diff_optimal = im_erased_optimal - im\n", + " diff_suboptimal = im_erased_suboptimal - im\n", + " err_subopt = diff_suboptimal.abs().mean() / im.abs().mean()\n", + " err_opt = diff_optimal.abs().mean() / im.abs().mean()\n", + " total_err_optimal += err_opt\n", + " total_err_suboptimal += err_subopt\n", + "\n", + " print(f\"Image {idx}:\")\n", + " print(f\"Average error for optimal: {total_err_optimal / (idx + 1)}\")\n", + " print(f\"Average error for suboptimal: {total_err_suboptimal / (idx + 1)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test visionprobe" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from mdl import VisionProbe\n", + "from concept_editing import get_train_test_data, evaluate_model\n", + "import torch\n", + "device = \"cuda\"\n", + "NUM_CLASSES = 10\n", + "X_train, X_test, Y_train, Y_test = get_train_test_data(\n", + " train_size=None, test_size=1024, flatten=False, device=device\n", + " )\n", + "\n", + "model = VisionProbe(\n", + " num_classes=NUM_CLASSES,\n", + " device=X_train.device,\n", + " dtype=torch.float32,\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model.train()\n", + "model.fit(X_train, Y_train, max_epochs=100, early_stop_epochs=4, reduce_lr_on_plateau=False, verbose=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from concept_editing import get_editor\n", + "editor = get_editor(\"linear\", X_train, Y_train)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "evaluate_model(model, X_test, Y_test, editor=editor)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ql", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/experiments/concept_editing.py b/experiments/concept_editing.py new file mode 100644 index 0000000..7bd757d --- /dev/null +++ b/experiments/concept_editing.py @@ -0,0 +1,304 @@ +import argparse +import json +import os +import random +from typing import Callable, Literal + +import numpy as np +import torch +import torchvision as tv +from concept_erasure import QuadraticFitter +from sklearn.metrics import accuracy_score +from torchvision.datasets import CIFAR10 +from torchvision.transforms.functional import to_tensor + +from mdl import MlpProbe, QuadraticProbe, VisionProbe +from mdl.probe import Probe + +NUM_CLASSES = 10 +IMAGE_SIZE = 32 + + +def fit_linear_editor(X: torch.Tensor, Z: torch.Tensor, num_classes: int): + """A linear editor is just a translation between class conditional means.""" + N, D = X.shape + assert Z.shape == (N,) + + translation_maps = torch.zeros( + (num_classes, num_classes, D), device=X.device + ) # i -> j + conditional_means = torch.zeros((num_classes, D), device=X.device) + for i in range(num_classes): + X_i = X[Z == i] + conditional_means[i] = X_i.mean(dim=0) + + for i in range(num_classes): + for j in range(num_classes): + translation_maps[i, j] = conditional_means[j] - conditional_means[i] + + def editor( + X_eval: torch.Tensor, source_z: torch.Tensor, target_z: torch.Tensor + ) -> torch.Tensor: + assert X_eval.shape[0] == len(source_z) == len(target_z) + assert X_eval.shape[1] == D + assert source_z.max() < num_classes + assert target_z.max() < num_classes + + device, dtype = X_eval.device, X_eval.dtype + + X_eval_target = ( + X_eval + translation_maps.to(device).to(dtype)[source_z, target_z] + ) + return X_eval_target + + return editor + + +def get_train_test_data( + download_dir: str = "/mnt/ssd-1/alexm/cifar10", + test_size: int | None = None, + train_size: int | None = None, + flatten: bool = False, + device="cuda", +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + train_data = CIFAR10(root=download_dir, download=True) + test_data = CIFAR10(root=download_dir, train=False, download=True) + X_train, Y_train = prepare_data( + train_data, size=train_size, flatten=flatten, device=device + ) + X_test, Y_test = prepare_data( + test_data, size=test_size, flatten=flatten, device=device + ) + print("Train+val size:", len(X_train)) + print("Test size:", len(X_test)) + return X_train, X_test, Y_train, Y_test + + +def prepare_data( + data: CIFAR10, size: int | None = None, flatten: bool = False, device="cuda" +): + images, labels = zip(*data) + + X = torch.stack(list(map(to_tensor, images))).to(device) # n x c x w x h + Y = torch.tensor(labels).to(X.device) + + # Shuffle deterministically + rng = torch.Generator(device=X.device).manual_seed(42) + perm = torch.randperm(len(X), generator=rng, device=X.device) + X, Y = X[perm][:size], Y[perm][:size] + + if flatten: + X = X.view(X.shape[0], -1) # n x d + + return X, Y + + +def train_model( + cls: type[Probe], X_train: torch.Tensor, Y_train: torch.Tensor +) -> Probe: + if cls == VisionProbe: + model = cls( + num_classes=NUM_CLASSES, + device=X_train.device, + dtype=torch.float32, + ) + else: + model = cls( + X_train.shape[1], + num_classes=NUM_CLASSES, + device=X_train.device, + dtype=torch.float32, + ) + model.fit( + X_train, + Y_train, + verbose=True, + max_epochs=100, + early_stop_epochs=4, + reduce_lr_on_plateau=False, + ) + return model + + +def get_editor( + kind: Literal["linear", "quadratic"], + X_train: torch.Tensor, + Y_train: torch.Tensor, + return_fitter=False, +): + # We need to flatten the data (in the VisionProbe case it's not already flat) + X_train = X_train.view(X_train.shape[0], -1).cpu().double() + Y_train = Y_train.cpu() + if kind == "quadratic": + fitter_cls = QuadraticFitter + fitter = fitter_cls.fit(X_train, Y_train) + fitter_editor = fitter.editor() + + def editor( + X_eval: torch.Tensor, source_z: torch.Tensor, target_z: torch.Tensor + ) -> torch.Tensor: + assert X_eval.shape[0] == len(source_z) == len(target_z) + assert X_eval.shape[1] == X_train.shape[1] + assert source_z.max() < NUM_CLASSES + assert target_z.max() < NUM_CLASSES + + X_eval = X_eval.to(X_train.device).to(X_train.dtype) + X_eval_target = X_eval.clone() + target_z = target_z.to(X_train.device) + source_z = source_z.to(X_train.device) + + for i in range(NUM_CLASSES): + X_eval_target[target_z == i] = fitter_editor( + X_eval[target_z == i], source_z[target_z == i].cpu(), i + ).to(X_eval.device) + return X_eval_target + + if return_fitter: + return editor, fitter + return editor + else: + return fit_linear_editor(X_train, Y_train, num_classes=NUM_CLASSES) + + +def evaluate_model( + model: Probe, + X_test: torch.Tensor = None, + Y_test: torch.Tensor = None, + editor: Callable = None, +) -> torch.Tensor: + model.eval() + + def get_logits(x, batch_size=64): + x_batches = x.to(model.dtype).split(batch_size) + logits = torch.cat([model(x_batch) for x_batch in x_batches]) + return logits + + def eval_metric(logits, y, metric: Literal["loss", "top1"] = "top1"): + if metric == "loss": + return model.loss_fn(logits, y).item() + else: + return float(accuracy_score(y.cpu(), logits.argmax(dim=1).cpu())) + + X_test_original = X_test.clone() + Y_test_original = Y_test.clone() + device = X_test.device + # 9x the data by making a copy of each row and editing the + # concept to be each of the 9 other classes + X_test = X_test.repeat(NUM_CLASSES, *([1] * (X_test.ndim - 1))) + Y_test = Y_test.repeat(NUM_CLASSES) + Y_target = ( + torch.arange(NUM_CLASSES, dtype=Y_test.dtype) + .repeat_interleave(len(X_test) // NUM_CLASSES) + .to(device) + ) + + # we must flatten the data before passing it to the fitter + X_test_flat = X_test.view(X_test.shape[0], -1) + X_test = ( + editor(X_test_flat, source_z=Y_test, target_z=Y_target) + .view(X_test.shape) + .to(device) + ) + Y_test = Y_test.to(device) + logits = get_logits(X_test) + logits_without_edit = get_logits(X_test_original) + results = dict() + for metric in ["loss", "top1"]: + results[metric] = eval_metric(logits_without_edit, Y_test_original, metric) + + for eval_against in ["source", "target"]: + Y_eval = Y_test if eval_against == "source" else Y_target + + # Make metric matrix + mat = np.zeros((NUM_CLASSES, NUM_CLASSES)) # source -> target + for source in range(NUM_CLASSES): + for target in range(NUM_CLASSES): + mask = (Y_test == source) & (Y_target == target) + mat[source, target] = eval_metric( + logits[mask], Y_eval[mask], metric + ) + results[f"{metric}_matrix_against_{eval_against}"] = mat.tolist() + + # Evaluate on all non-id edits + mask = Y_target != Y_test + results[f"{metric}_against_{eval_against}_edited"] = eval_metric( + logits[mask], Y_eval[mask], metric + ) + return results + + +def main(args): + padding = round(IMAGE_SIZE * 0.125) + + augmentor = tv.transforms.Compose( + [ + tv.transforms.RandomCrop(IMAGE_SIZE, padding=padding), + tv.transforms.RandomHorizontalFlip(), + ] + ) + + results = [] + seeds = [0, 1, 2, 3, 4] + for seed in seeds: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + torch.backends.cudnn.deterministic = True + model_configs = [ + (MlpProbe, dict(num_layers=3)), + (VisionProbe, dict(augmentor=augmentor)), + (QuadraticProbe, dict()), + ] + editing_modes = ["quadratic", "linear"] + for cls, cfg in model_configs: + cfg_str = f"(num_layers={cfg['num_layers']})" if cls == MlpProbe else "" + print(f"Training {cls.__name__} {cfg_str}...") + X_train, X_test, Y_train, Y_test = get_train_test_data( + train_size=args.train_size, + test_size=args.test_size, + flatten=cls != VisionProbe, + download_dir=args.download_dir, + device=args.device, + ) + model = train_model(cls, X_train, Y_train) + + for editing_mode in editing_modes: + print(f"Evaluating {cls.__name__} with {editing_mode} editor...") + + if cls == VisionProbe and args.augment_before_edit: + X_train_for_edit = model.augment_data(X_train) + else: + X_train_for_edit = X_train + editor = get_editor(editing_mode, X_train_for_edit, Y_train) + with torch.no_grad(): + eval_result = evaluate_model(model, X_test, Y_test, editor) + results.append( + { + "model": cls.__name__ + cfg_str, + "editing_mode": editing_mode, + "n_test": int(X_test.shape[0]), + "n_train": int(X_train.shape[0]), + "seed": seed, + **eval_result, + } + ) + for k, v in results[-1].items(): + print(f"{k}: {v}") + print() + + with open(os.path.join(args.out_dir, "CIFAR_editing_results.json"), "w") as f: + json.dump(results, f) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--device", type=str, default="cuda") + parser.add_argument("--download-dir", type=str, default="/mnt/ssd-1/alexm/cifar10") + parser.add_argument("--train-size", type=int, default=None) + parser.add_argument("--test-size", type=int, default=None) + parser.add_argument("--out-dir", type=str, default=".") # "../data/" + parser.add_argument("--augment-before-edit", action="store_true", default=True) + + args = parser.parse_args() + main(args) diff --git a/experiments/erasure_sweep.ipynb b/experiments/erasure_sweep.ipynb new file mode 100644 index 0000000..12c7541 --- /dev/null +++ b/experiments/erasure_sweep.ipynb @@ -0,0 +1,254 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/mnt/ssd-1/alexm/miniconda3/envs/ql/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "from mdl import Sweep, MlpProbe, QuadraticProbe\n", + "from concept_erasure import QuadraticFitter, OracleFitter\n", + "from datasets import load_dataset\n", + "import torch\n", + "from typing import Literal\n", + "# autoreload\n", + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "random_seed = None # None means not random\n", + "ds_name = \"atmallen/amazon_polarity_embeddings\" + (f\"_random{random_seed}\" if random_seed else \"\")\n", + "ds_dict = load_dataset(ds_name)\n", + "ds_dict = ds_dict.with_format(\"torch\", columns=[\"embedding\", \"label\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "device = \"cuda\"\n", + "n_train = 2**14\n", + "erasure: Literal[\"Linear\", \"Q-LEACE\", \"none\"] = \"Q-LEACE\"\n", + "seed = 0" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "num_classes = ds_dict[\"train\"].features[\"label\"].num_classes\n", + "X_train = ds_dict[\"train\"][\"embedding\"][:n_train]\n", + "X_train = X_train / X_train.norm(dim=-1, keepdim=True)\n", + "Y_train = ds_dict[\"train\"][\"label\"][:n_train]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fitter = QuadraticFitter.fit(X_train, Y_train)\n", + "eraser = fitter.eraser\n", + "X_train = eraser(X_train, Y_train)" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 25%|██▌ | 1/4 [00:00<00:00, 8.04scales/s, loss=1.0000]" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 4/4 [02:26<00:00, 36.64s/scales, loss=0.4694]\n" + ] + } + ], + "source": [ + "\n", + "sweep = Sweep(\n", + " num_features=X_train.shape[1],\n", + " num_classes=num_classes,\n", + " num_chunks=5, # TODO: change to 10\n", + " # probe_cls=QuadraticProbe,\n", + " probe_cls=MlpProbe,\n", + " val_frac=0.2,\n", + " device=device,\n", + " probe_kwargs=dict(\n", + " num_layers=2,\n", + " )\n", + ")\n", + "result = sweep.run(X_train.to(device), Y_train.to(device).to(float), seed=seed)" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MdlResult(mdl=1.9073904481607726, ce_curve=[1.0000008462251695, 0.5488809365661911, 0.5157688135785602, 0.4694291334366037], sample_sizes=[768, 2069, 4273, 8008, 14336], total_trials=0)" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MdlResult(mdl=1.733752111798162, ce_curve=[0.5765370022805786, 0.5018573319388311, 0.4810841569008844, 0.441685225059591, 0.4772105041717546, 0.4166916619506471, 0.4115986014027328, 0.40568870439379623, 0.40814509824604545], sample_sizes=[768, 1984, 3909, 6957, 11783, 19424, 31523, 50678, 81006, 129024], total_trials=0)" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result_no_erase" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "torch.Size([16384, 384])" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "X_train.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Epoch: 0%| | 0/100 [00:00 tuple[list[int], list[int], int]: + possible_conv_sizes = [32, 40, 48, 64, 128] + possible_fc_sizes = [64, 128, 256, 512, 1024, 2048] + kernel_sizes = [5, 5] + num_channels = 3 + num_labels = 10 + + best_diff = float('inf') + best_config = None + + for conv1, conv2 in itertools.product(possible_conv_sizes, repeat=2): + for fc1, fc2 in itertools.product(possible_fc_sizes, repeat=2): + cfg = LeNetConfig( + image_size=image_size, + num_channels=num_channels, + conv_hidden_sizes=[conv1, conv2], + fc_hidden_sizes=[fc1, fc2], + kernel_sizes=kernel_sizes, + num_labels=num_labels + ) + + params = lenet_parameter_count(cfg) + diff = abs(params - target_params) + + if diff < best_diff: + best_diff = diff + best_config = ([conv1, conv2], [fc1, fc2], params) + + if best_config is None: + raise ValueError("No valid configuration found") + + return best_config + + +def main(): + # Generate configurations for both image sizes + widths, depths = sweep_params['mlp']['widths'], sweep_params['mlp']['depths'] + + configs_32 = {} + configs_64 = {} + + for depth in depths: + for width in widths: + mlp_params = mlp_parameter_count(depth, width, input_size=3072) # 32x32x3 + conv_32, fc_32, actual_32 = find_closest_config(mlp_params, 32) + + mlp_params_64 = mlp_parameter_count(depth, width, input_size=12288) # 64x64x3 + conv_64, fc_64, actual_64 = find_closest_config(mlp_params_64, 64) + + configs_32[f"{depth}_{width}"] = { + 'conv_hidden_sizes': conv_32, + 'fc_hidden_sizes': fc_32, + 'params': actual_32, + 'target_params': mlp_params + } + + configs_64[f"{depth}_{width}"] = { + 'conv_hidden_sizes': conv_64, + 'fc_hidden_sizes': fc_64, + 'params': actual_64, + 'target_params': mlp_params_64 + } + + with open('data/lenet_configs_32.json', 'w') as f: + json.dump(configs_32, f, indent=2) + + print(configs_64) + with open('data/lenet_configs_64.json', 'w') as f: + json.dump(configs_64, f, indent=2) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/experiments/iterative_erasure.py b/experiments/iterative_erasure.py new file mode 100644 index 0000000..ce75bd4 --- /dev/null +++ b/experiments/iterative_erasure.py @@ -0,0 +1,287 @@ +from pathlib import Path +from simple_parsing import ArgumentParser +from dataclasses import dataclass + +import torch +from torch import nn, optim, Tensor +import torchvision.utils as vutils +from torchvision import transforms +from datasets import ClassLabel, Dataset, DatasetDict, Features, Image, load_dataset +from concept_erasure import assert_type, groupby, optimal_linear_shrinkage +from PIL import Image as PilImage +from huggingface_hub import HfApi +import lovely_tensors as lt + +from experiments.cli import get_cifar10 +from torchvision.datasets import CIFAR10 +from torchvision.transforms.v2.functional import to_dtype, to_image + +lt.monkey_patch() + + +def set_seeds(seed=0): + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + + +hyperparameters = { + "cifar10": { + "mse_weight": 1., + "cov_weight": 0.01, + "mean_weight": 0.01, + }, + "cifarnet": { + "mse_weight": 1e-9, + "cov_weight": 0.5, + "mean_weight": 1., + }, + "svhn": { + "mse_weight": 1., + "cov_weight": 0.01, + "mean_weight": 0.01, + }, +} + +@dataclass +class Args: + dataset: str = "cifar10" + num_classes: int = 10 + max_iter: int = 100 + # 1000 results in human-interpretable images, 100 is marginal + prefix: str = "erased" + mse_weight: float | None = None + cov_weight: float | None = None + mean_weight: float | None = None + + +def transform_dataset(args: Args): + def transform_cifarnet_to_statistics(data: Tensor, target_mean: Tensor, target_cov: Tensor, *, max_iter: int, mean_weight: float, cov_weight: float, mse_weight: float): + """Transform existing data points to match target statistics while preserving structure.""" + n, d = data.shape + assert d == target_mean.shape[-1] == target_cov.shape[-1] == target_cov.shape[-2] + assert n > 1, "Need at least two samples to compute covariance" + + z = nn.Parameter(data.clone()) + target_data = data + opt = optim.Adam([z], lr=1e-2) + + def closure(): + opt.zero_grad() + x = z + mean_loss = torch.norm(x.mean(0) - target_mean) + cov_loss = torch.norm(x.T.cov() - target_cov) + mse_loss = ((x - target_data) ** 2).mean((0, 1)) + + loss = (mean_loss * mean_weight) + (cov_loss * cov_weight) + (mse_weight * mse_loss) + print(loss, "mean loss", (mean_loss * mean_weight).item(), "cov loss", (cov_loss * cov_weight).item(), "weighted mse loss", (mse_loss * mse_weight).item()) + + loss.backward() + return float(loss) + + for _ in range(max_iter): + loss = closure() + opt.step() + return z.detach() + + + def transform_to_statistics(data: Tensor, target_mean: Tensor, target_cov: Tensor, *, max_iter: int, mean_weight: float, cov_weight: float, mse_weight: float): + """Transform existing data points to match target statistics while preserving structure.""" + n, d = data.shape + assert d == target_mean.shape[-1] == target_cov.shape[-1] == target_cov.shape[-2] + assert n > 1, "Need at least two samples to compute covariance" + + eps = torch.finfo(data.dtype).eps + x = torch.clamp(data, eps, 1 - eps) + z = nn.Parameter(x.logit()) + + target_mean = torch.clamp(target_mean, eps, 1 - eps) + target_mean = target_mean.logit().sigmoid() + + target_cov = torch.clamp(target_cov, eps, 1 - eps) + target_cov = target_cov.logit().sigmoid() + + target_data = x.logit().sigmoid() + + opt = optim.LBFGS([z], line_search_fn="strong_wolfe", max_iter=max_iter) + + def closure(): + opt.zero_grad() + + x = z.sigmoid() + mean_loss = torch.norm(x.mean(0) - target_mean) + cov_loss = torch.norm(x.T.cov() - target_cov) + mse_loss = ((x - target_data) ** 2).mean((0, 1)) + + loss = (mean_loss * mean_weight) + (cov_loss * cov_weight) + (mse_weight * mse_loss) + print(loss, "mean loss", (mean_loss * mean_weight).item(), "cov loss", (cov_loss * cov_weight).item(), "weighted mse loss", (mse_loss * mse_weight).item()) + + loss.backward() + return float(loss) + + opt.step(closure) + return z.sigmoid().detach() + + + def process_split(split: str): + if args.dataset == "cifarnet": + ds = assert_type(Dataset, load_dataset(f"EleutherAI/{args.dataset}", split=split)) + elif args.dataset == "cifar10": + if split == 'test': + data = CIFAR10("data/cache/cifar10-test", download=True, train=False) + else: + print("matched cifar10") + exit(0) + data = CIFAR10("data/cache/cifar10", download=True) + images, labels = zip(*data) + X = torch.stack([ + to_dtype(to_image(item), dtype=torch.float32, scale=True) + for item in images + ]).to('cuda') + Y = torch.tensor(labels).to('cuda') + ds = Dataset.from_dict({ + "image": X * 255, + "label": Y + }) + elif args.dataset == "svhn": + ds = assert_type(Dataset, load_dataset("ufldl-stanford/svhn", "cropped_digits", split=split)) + else: + ds = assert_type(Dataset, load_dataset(args.dataset, split=split)) + + if "img" in ds.column_names: + ds = ds.rename_column("img", "image") + + with ds.formatted_as("torch"): + X = assert_type(Tensor, ds["image"]).div(255) + Y = assert_type(Tensor, ds["label"]) + + # Calculate global statistics + flattened_X = X.flatten(1) + global_mean = flattened_X.mean(0).cpu() + global_cov = optimal_linear_shrinkage(flattened_X.mT.cov(), len(ds)).cpu() + del flattened_X + + transformed_images = [] + transformed_labels = [] + + # Transform each class to match global statistics + means = [] + covs = [] + for y, x in groupby(X, Y): + flat_x = x.flatten(1) + + # TODO Print original cov and mean norm differences + print(f"Original mean norm for {y}", torch.norm(x.flatten(1).mean(0)).item()) + print(f"Original cov norm difference for {y}", torch.norm(x.flatten(1).T.cov()).item()) + + if args.dataset == "cifarnet": + transformed = transform_cifarnet_to_statistics( + flat_x, + global_mean, + global_cov, + max_iter=args.max_iter, + mean_weight=args.mean_weight, + cov_weight=args.cov_weight, + mse_weight=args.mse_weight + ) + else: + transformed = transform_to_statistics( + flat_x, + global_mean, + global_cov, + max_iter=args.max_iter, + mean_weight=args.mean_weight, + cov_weight=args.cov_weight, + mse_weight=args.mse_weight + ) + means.append(transformed.mean(0)) + covs.append(transformed.T.cov()) + + # Fix: Reshape to [N, 3, 32, 32] then permute to [N, 32, 32, 3] for PIL + reshaped = transformed.reshape_as(x).permute(0, 2, 3, 1).mul(255).clip(0, 255).byte() + transformed_images.extend([ + PilImage.fromarray(img.cpu().numpy(), mode='RGB') + for img in reshaped + ]) + transformed_labels.extend([y] * len(x)) + + # Print average cosine similarity between transformed classes + for i in range(1, len(means)): + torch.testing.assert_close(means[i], global_mean, rtol=0.5, atol=0.5) + torch.testing.assert_close(covs[i], global_cov, rtol=0.5, atol=0.5) + + mean_mse = nn.MSELoss()(means[i], global_mean) + cov_mse = nn.MSELoss()(covs[i], global_cov) + print(f"mean and cov mse for class {i}", mean_mse.item(), cov_mse.item()) + + # Get indices of first occurrence of each class + print("Saving sample images") + unique_labels = [] + unique_indices = [] + original_tensors = [] + for i, label in enumerate(transformed_labels): + if label not in unique_labels: + unique_labels.append(label) + unique_indices.append(i) + + # Get original image index + original_tensors.append(X[Y == label][0]) + + if len(unique_labels) == 10: # Assuming 10 classes (e.g., CIFAR-10, CIFARNet) + break + + selected_tensors = torch.stack([transforms.ToTensor()(transformed_images[i]) for i in unique_indices]) + + Path('data/saved_images').mkdir(exist_ok=True) + for i, (tensor, orig_tensor, label) in enumerate(zip(selected_tensors, original_tensors, unique_labels)): + vutils.save_image(tensor, f'data/saved_images/class_{label}_{args.dataset}_{args.mse_weight}_{args.cov_weight}_{args.mean_weight}.png', normalize=True) + vutils.save_image(orig_tensor, f'data/saved_images/class_{label}_original_{args.dataset}.png', normalize=True) + + return Dataset.from_dict({ + "image": transformed_images, + "label": transformed_labels + }) + + features = Features({ + "image": Image(), + "label": ClassLabel(num_classes=args.num_classes), + }) + + transformed_train = process_split("train").cast(features) + transformed_test = process_split("test").cast(features) + + return DatasetDict({ + "train": transformed_train, + "test": transformed_test + }) + + +if __name__ == "__main__": + set_seeds() + + parser = ArgumentParser() + parser.add_arguments(Args, dest="args") + args = parser.parse_args().args + args.mse_weight = args.mse_weight or hyperparameters[args.dataset]["mse_weight"] + args.cov_weight = args.cov_weight or hyperparameters[args.dataset]["cov_weight"] + args.mean_weight = args.mean_weight or hyperparameters[args.dataset]["mean_weight"] + + transformed = transform_dataset(args) + + # Save to disk + # transformed.save_to_disk("data/eraser-order-cifar10") + + # Upload to hub + api = HfApi() + api.whoami() + + repo_id = f"EleutherAI/erased-{args.dataset}" + + api.create_repo(repo_id, repo_type="dataset", exist_ok=True) + + transformed.push_to_hub( + repo_id, + private=False, + commit_message=f"Upload transformed {args.dataset} dataset" + ) diff --git a/experiments/leaced_iterative_erasure.py b/experiments/leaced_iterative_erasure.py new file mode 100644 index 0000000..16fc403 --- /dev/null +++ b/experiments/leaced_iterative_erasure.py @@ -0,0 +1,238 @@ +# Do reparametrization with Adam +# Ensure clipping is done +# Clip after LEACE which brings outside the hypercube + + +from pathlib import Path +from simple_parsing import ArgumentParser +from dataclasses import dataclass + +import torch +from torch import nn, optim, Tensor +import torchvision.utils as vutils +from torchvision import transforms +from datasets import ClassLabel, Dataset, DatasetDict, Features, Image, load_dataset +from concept_erasure import assert_type, groupby, optimal_linear_shrinkage +from PIL import Image as PilImage +from huggingface_hub import HfApi +import lovely_tensors as lt + +from experiments.cli import get_cifar10, load_eraser, get_cifarnet, get_fake_cifar10, get_fake_cifarnet, get_svhn, get_fake_svhn, IdentityEraser +from torchvision.datasets import CIFAR10 +from torchvision.transforms.v2.functional import to_dtype, to_image +import torch.nn.functional as F +from concept_erasure import assert_type, groupby, optimal_linear_shrinkage +from dataclasses import dataclass +from typing import Literal + +lt.monkey_patch() + + +def set_seeds(seed=0): + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + + +hyperparameters = { + "cifar10": { + "mse_weight": 1., + "cov_weight": 0.01, + "mean_weight": 0.01, + }, + "cifarnet": { + "mse_weight": 1e-9, + "cov_weight": 0.5, + "mean_weight": 1., + }, + "svhn": { + "mse_weight": 1., + "cov_weight": 0.01, + "mean_weight": 0.01, + }, +} + +@dataclass +class CombinedErasureArgs: + dataset: str = "cifar10" + num_classes: int = 10 + max_iter: int = 100 + prefix: str = "erased" + mse_weight: float | None = None + cov_weight: float | None = None + mean_weight: float | None = None + method: Literal["leace", "orth", "none"] = "leace" + shrinkage: bool = False + linear_cache_key: str | None = None + + def __post_init__(self): + # Set default weights based on dataset if not provided + if self.dataset in hyperparameters: + if self.mse_weight is None: + self.mse_weight = hyperparameters[self.dataset]["mse_weight"] + if self.cov_weight is None: + self.cov_weight = hyperparameters[self.dataset]["cov_weight"] + if self.mean_weight is None: + self.mean_weight = hyperparameters[self.dataset]["mean_weight"] + +def transform_with_combined_erasure(args: CombinedErasureArgs, X: Tensor, Y: Tensor, cached_linear_eraser): + """ + Transform data by first applying cached linear erasure, then quadratic erasure. + + Args: + args: Configuration parameters + X: Input tensor of shape [N, C, H, W] + Y: Target tensor of shape [N] + cached_linear_eraser: Pre-computed linear eraser from cache + """ + device = X.device + flattened_X = X.flatten(1) + n, d = flattened_X.shape + + # Step 1: Apply cached linear eraser if provided + flattened_X = cached_linear_eraser.to(flattened_X.device)(flattened_X) + + + # Calculate global statistics for quadratic erasure + global_mean = flattened_X.mean(0) + global_cov = optimal_linear_shrinkage(flattened_X.mT.cov(), len(X)) + + def transform_to_statistics(data: Tensor, target_mean: Tensor, target_cov: Tensor): + """Transform data points to match target statistics while preserving structure.""" + eps = torch.finfo(data.dtype).eps + x = torch.clamp(data, eps, 1 - eps) + z = nn.Parameter(x.logit()) + + print(z.device, 'z device') + + target_mean = torch.clamp(target_mean, eps, 1 - eps) + target_mean = target_mean.logit().sigmoid() + + target_cov = torch.clamp(target_cov, eps, 1 - eps) + target_cov = target_cov.logit().sigmoid() + + target_data = x.logit().sigmoid() + + opt = optim.LBFGS([z], line_search_fn="strong_wolfe", max_iter=args.max_iter) + + def closure(): + opt.zero_grad() + x = z.sigmoid() + mean_loss = torch.norm(x.mean(0) - target_mean) + cov_loss = torch.norm(x.T.cov() - target_cov) + mse_loss = ((x - target_data) ** 2).mean((0, 1)) + + loss = (mean_loss * args.mean_weight) + \ + (cov_loss * args.cov_weight) + \ + (args.mse_weight * mse_loss) + + print(f"loss {loss}, mean loss {(mean_loss * args.mean_weight).item()}, cov loss {(cov_loss * args.cov_weight).item()}, weighted mse loss {(mse_loss * args.mse_weight).item()}") + + loss.backward() + return float(loss) + + opt.step(closure) + return z.sigmoid().detach() + + transformed_data = [] + transformed_labels = [] + + # Transform each class to match global statistics + for y, x in groupby(flattened_X, Y): + print(f"Original mean norm for {y}", torch.norm(x.mean(0)).item()) + print(f"Original cov norm difference for {y}", torch.norm(x.T.cov()).item()) + + transformed = transform_to_statistics(x, global_mean, global_cov) + + # Print statistics for verification + mean_mse = nn.MSELoss()(transformed.mean(0), global_mean) + cov_mse = nn.MSELoss()(transformed.T.cov(), global_cov) + print(f"mean and cov mse for class {y}", mean_mse.item(), cov_mse.item()) + + transformed_data.append(transformed) + transformed_labels.extend([y] * len(x)) + + # Combine all transformed data + transformed_data = torch.cat(transformed_data, dim=0) + transformed_labels = torch.tensor(transformed_labels, device=device) + + # Reshape back to original image dimensions + transformed_data = transformed_data.reshape(X.shape) + + return transformed_data, transformed_labels + + +if __name__ == "__main__": + from argparse import ArgumentParser + parser = ArgumentParser() + parser.add_argument("--dataset", type=str, default="cifar10") + parser.add_argument("--method", type=str, default="leace") + parser.add_argument("--shrinkage", type=bool, default=False) + args = parser.parse_args() + + args = CombinedErasureArgs( + dataset=args.dataset, + ) + + # Get dataset + device = "cuda" + (X_train, Y_train, X_val, Y_val, k, X, Y) = { + "cifar10": get_cifar10(device), + "cifarnet": get_cifarnet(), + "fake-cifar10": get_fake_cifar10(), + "fake-cifarnet": get_fake_cifarnet(), + "svhn": get_svhn(device), + "fake-svhn": get_fake_svhn(), + }[args.dataset] + + num_features = X.shape[1] * X.shape[2] * X.shape[3] + + # Load your cached linear eraser + cached_eraser = load_eraser( + eraser_str="leace", + dataset_str=args.dataset, + X_train=X_train, + Y_train=Y_train, + dtype=torch.float32, + method=args.method, + shrinkage=args.shrinkage, + alf_qleace_target=-1., + num_features=num_features, + k=k, + nowritecache=True, + device="cuda", + fit_device="cuda", + + ) + print(f"Train data shape: {X_train.shape}, {Y_train.shape}") + print(f"Val data shape: {X_val.shape}, {Y_val.shape}") + + # Apply combined erasure + data_dict = {} + for split, X_data, Y_data in [("train", X_train, Y_train), ("val", X_val, Y_val)]: + transformed_X, transformed_Y = transform_with_combined_erasure(args, X_data, Y_data, cached_eraser) + dataset = Dataset.from_dict({"image": transformed_X, "label": transformed_Y}) + data_dict[split] = dataset + + import os + # # Save to disk as HF dataset, LEACE-and-quadratic-iterative-erasure + data_dir = f"data/leace-and-quadratic-iterative-erasure-{args.dataset}" + + for split, ds in data_dict.items(): + ds.save_to_disk(os.path.join(data_dir, split)) + + # split = "train" + # from datasets import load_from_disk + # ab = load_from_disk((os.path.join(data_dir, split))) + # ab.set_format(type="torch", columns=["image", "label"]) + + # # Write sample images to disk + # Path('data/saved_images').mkdir(exist_ok=True) + # Path('data/saved_images/leaced_iterative_erasure').mkdir(exist_ok=True) + # for i, (tensor, label) in enumerate(zip(ab["image"], ab["label"])): + # vutils.save_image(tensor, f'data/saved_images/leaced_iterative_erasure/class_{label}_original_{args.dataset}.png', normalize=True) + + + + # breakpoint() diff --git a/experiments/plot/condensed_mdl.py b/experiments/plot/condensed_mdl.py new file mode 100644 index 0000000..e92af10 --- /dev/null +++ b/experiments/plot/condensed_mdl.py @@ -0,0 +1,211 @@ + +import pandas as pd +from argparse import ArgumentParser, Namespace +from pathlib import Path +import torch +import plotly.graph_objects as go +from plotly.subplots import make_subplots +import numpy as np +import plotly.io as pio +import plotly.express as px + + +from experiments.plot.plot_mdl import DISPLAY_NAMES, load_sweep_data +from experiments.sweep_eraser import sweep_params + + +def create_plots(df: pd.DataFrame, output_dir: Path, dataset: str): + output_dir.mkdir(exist_ok=True, parents=True) + colors = px.colors.qualitative.Plotly + + ordered_erasers = [ + e + for e in ["QLEACE", "Iterative Erasure", "LEACE and Iterative Erasure", "ALF-QLEACE", "LEACE", "Control",] + if e in df["eraser"].unique() + ] + + df = df[df["dataset"] == dataset].sort_values(["depth", "width"]) + # unique_nets = df["net_id"].unique() + + # if dataset == "cifar10": + # unique_nets = ['mlp', 'resmlp', 'lenet', 'swin', 'convnext'] + # else: + unique_nets = ['mlp', 'resmlp', 'lenet'] + + n_rows = len(unique_nets) + + titles = [DISPLAY_NAMES[net_id] for net_id in unique_nets] + titles = [(title, "") for title in titles] + + # flatten into a single list + titles = [item for sublist in titles for item in sublist] + + # Calculate global y-axis range + y_min = df[df["act"] == "ReLU"]["mdl"].min() + y_max = df[df["act"] == "ReLU"]["mdl"].max() + + # Add some padding to the range + y_range = [y_min - 0.05 * (y_max - y_min), y_max + 0.05 * (y_max - y_min)] + + # Create subplot grid + fig = make_subplots( + rows=n_rows, + cols=2, + horizontal_spacing=0.04, + vertical_spacing=0.04, + subplot_titles=titles, + + ) + + for i in range(len(fig.layout.annotations)): + # Skip empty titles (if you only want to move non-empty ones) + if fig.layout.annotations[i].text != "": + # To center the title over its subplot: + fig.layout.annotations[i].x = 0.5 # 0.5 is center + fig.layout.annotations[i].y += 0.006 + + # If you want to ensure the title stays anchored to this position: + fig.layout.annotations[i].xanchor = 'center' + + # Update overall layout + fig.update_layout( + height=300 * n_rows, + width=1200, + showlegend=True, + legend=dict( + title="Eraser type", + yanchor="top", + y=0.98, + xanchor="right", + x=0.95, + bgcolor="rgba(255, 255, 255, 0.9)" + ), + ) + + # Process each network + for row_idx, net_id in enumerate(unique_nets, 1): + net = DISPLAY_NAMES[net_id] + reference_width = sweep_params[net_id]["mup_width"] + reference_depth = sweep_params[net_id]["mup_depth"] + + # Set up y-axes + for col in [1, 2]: + fig.update_yaxes( + title_text="MDL (bits per sample)" if col == 1 else "", + showticklabels=True, + row=row_idx, + col=col, + range=y_range, + ) + + + # Set up x-axes (only for bottom row) + if row_idx == n_rows: + for col, param in enumerate(["depth", "width"], 1): + param_data = df[df["net_id"] == net_id][param].unique() + if len(param_data) == 0: + continue + + # Configure ticks for this specific subplot + tick_vals = [2**i for i in range( + int(np.log2(min(param_data))), + int(np.log2(max(param_data))) + 1, + )] + tick_text = [f"2{i}" for i in range( + int(np.log2(min(param_data))), + int(np.log2(max(param_data))) + 1, + )] + + fig.update_xaxes( + type="log", + tickvals=tick_vals, + ticktext=tick_text if row_idx == n_rows else None, # Only show labels on bottom row + showticklabels=(row_idx == n_rows), # Only show labels on bottom row + title_text=param.title() if row_idx == n_rows else "", # Only show title on bottom row + row=row_idx, + col=col, + ) + + net_df = df[(df["net"] == net) & (df["act"] == "ReLU")] + + # Plot data for each eraser type + for eraser_idx, eraser in enumerate(ordered_erasers): + data = net_df[net_df["eraser"] == eraser] + if data.empty: + print(f"Skipping {eraser} for {net}") + continue + + mean_data = ( + data.groupby(["width", "depth"])["mdl"] + .agg(["mean", "std"]) + .reset_index() + ) + + # Plot depth sweep (fixed width) + depth_data = data[data["width"] == reference_width] + mean_depth = mean_data[mean_data["width"] == reference_width] + + # Plot width sweep (fixed depth) + width_data = data[data["depth"] == reference_depth] + mean_width = mean_data[mean_data["depth"] == reference_depth] + + for col, (param_data, mean_param) in enumerate( + [ + (depth_data, mean_depth), + (width_data, mean_width), + ], + 1, + ): + param = "depth" if col == 1 else "width" + + # Plot individual points + fig.add_trace( + go.Scatter( + x=param_data[param], + y=param_data["mdl"], + mode="markers", + marker=dict(color=colors[eraser_idx], size=5, opacity=0.3), + showlegend=False, + ), + row=row_idx, + col=col, + ) + + # Plot mean line (only show legend for first row) + fig.add_trace( + go.Scatter( + x=mean_param[param], + y=mean_param["mean"], + mode="lines+markers", + line=dict(width=2), + marker=dict(color=colors[eraser_idx]), + name=eraser, + showlegend=(row_idx == 1 and col == 1), + ), + row=row_idx, + col=col, + ) + + fig.write_image(output_dir / f"all_models_MDL_{dataset}.pdf", format="pdf") + + + +def main(): + parser = ArgumentParser() + parser.add_argument("--data", type=Path, default=Path("24-11-21")) + parser.add_argument("--dataset", type=str, default="cifar10") + parser.add_argument("--out", type=Path, default=Path("data/images/sweep_plots")) + args: Namespace = parser.parse_args() + + if args.dataset == "cifar10": + assert "cifarnet" not in args.data.name + + print("Loading disk data into dataframe...") + df = load_sweep_data(args.data) + + print("Creating plots...") + create_plots(df, args.out, args.dataset) + + +if __name__ == "__main__": + main() diff --git a/experiments/plot/plot_cov_eigenvalues.py b/experiments/plot/plot_cov_eigenvalues.py new file mode 100644 index 0000000..999d79a --- /dev/null +++ b/experiments/plot/plot_cov_eigenvalues.py @@ -0,0 +1,109 @@ +from argparse import ArgumentParser +from pathlib import Path + +import torch +import torch.nn.functional as F +from concept_erasure.quadratic import QuadraticFitter +from concept_erasure.leace import LeaceFitter +from concept_erasure.alf_qleace import AlfQLeaceFitter +from torch import Tensor +import lovely_tensors as lt +import plotly.express as px + +from experiments.cli import get_cifar10, get_cifarnet, IdentityEraser + + +if __name__ == "__main__": + lt.monkey_patch() + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + dtype = torch.float32 + + parser = ArgumentParser() + parser.add_argument("--eraser", type=str, choices=("control", "leace", "oleace", "qleace", "alf_qleace"), default="control") + parser.add_argument("--dataset", type=str, choices=("cifar10", "cifarnet"), default="cifar10") + parser.add_argument("--nocache", action="store_true") + args = parser.parse_args() + + (X_train, Y_train, X_val, Y_val, k, X, Y) = { + "cifar10": get_cifar10(device="cuda"), + "cifarnet": get_cifarnet(), + }[args.dataset] + + num_features = X.shape[1] * X.shape[2] * X.shape[3] + + # Populate eraser cache using training data + state_path = Path("data") / "erasers_cache" / f"{args.dataset}_{dtype}_state.pth" + state_path.parent.mkdir(exist_ok=True) + state = {} if not state_path.exists() else torch.load(state_path, weights_only=False) + + if args.eraser not in state or args.nocache: + if args.eraser == "control": + state[args.eraser] = IdentityEraser() + else: + cls = { + "leace": LeaceFitter, + "qleace": QuadraticFitter, + "alf_qleace": AlfQLeaceFitter, + }[args.eraser] + + dtype = torch.float32 + + fitter = cls( + num_features, k, dtype=dtype, device=device, shrinkage=True + ) + + Y_tensor = ( + F.one_hot(Y_train, k) + if args.eraser != "qleace" + else Y_train + ).to(device) + X_tensor = X_train.flatten(1).to(device).to(dtype) + fitter.update(X_tensor, Y_tensor) + + if args.dataset == "cifarnet": + fitter = fitter.to("cpu") + eraser = fitter.eraser + + state[args.eraser] = fitter.eraser + torch.save(state, state_path) + + eraser = state[args.eraser] + + + # Unerased SVD + def get_flipped_eigenvalues(data: Tensor, log=True): + if not log: + raise NotImplementedError("Only log scale is supported") + + cov = data.flatten(1).T.cov() + eigenvals = torch.linalg.eigvalsh(cov) + + # Add 1 to allow log scale + return torch.cat((torch.tensor([1], device=eigenvals.device), eigenvals.flip(dims=(0,)))) + + # SVD of centered data, singular values = square roots of eigenvalues of covariance matrix + # SVD on covariance matrix, identical to eigenvalues + + # Eigenvalues of data covariance + flipped_eigenvalues = { + 'control': get_flipped_eigenvalues(X_train, log=True).cpu() + } + + for eraser_str in ('leace', 'qleace', 'alf_qleace'): + eraser = state[eraser_str].to('cuda') + erased = ( + eraser(X_train.cuda().flatten(1), Y_train) + if eraser_str == "qleace" + else eraser(X_train.flatten(1)).reshape(X_train.shape) + ) + + flipped_eigenvalues[eraser_str] = get_flipped_eigenvalues(erased, log=True).cpu() + + all_flipped = torch.cat(list(flipped_eigenvalues.values())) + global_min = torch.log(torch.min(all_flipped)).item() + global_max = torch.log(torch.max(all_flipped)).item() + + for eraser_str, erased_flipped in flipped_eigenvalues.items(): + fig = px.line(x=range(len(erased_flipped)), y=erased_flipped, title=f"{eraser_str} data covariance eigenvalues spectrum", log_x=True, log_y=True) + fig.update_layout(xaxis_title="Reversed eigenvalue index", yaxis_title="Eigenvalue", yaxis_range=[global_min, global_max]) + fig.write_image(f"svd_{eraser_str}.png") \ No newline at end of file diff --git a/experiments/plot/plot_gain.py b/experiments/plot/plot_gain.py new file mode 100644 index 0000000..4b4be0a --- /dev/null +++ b/experiments/plot/plot_gain.py @@ -0,0 +1,419 @@ +from pathlib import Path +from argparse import ArgumentParser + +import pandas as pd +import numpy as np +import plotly.express as px +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from experiments.sweep_eraser import sweep_params +from experiments.plot.plot_mdl import load_sweep_data + + +def analyze_conv_gain(df: pd.DataFrame, out: Path, tag: str): + """Create plots showing how convolutional advantage varies with width/depth.""" + out.mkdir(exist_ok=True) + + reference_width = sweep_params["mlp"]["mup_width"] + reference_depth = sweep_params["mlp"]["mup_depth"] + width_depths = [(width, reference_depth) for width in sweep_params["mlp"]["widths"]] + widths_depth = [(reference_width, depth) for depth in sweep_params["mlp"]["depths"]] + + def diff_of_diffs( + lenet_unerased_metric, + lenet_erased_metric, + mlp_unerased_metric, + mlp_erased_metric, + lenet_unerased_std, + lenet_erased_std, + mlp_unerased_std, + mlp_erased_std, + lenet_unerased_n, + lenet_erased_n, + mlp_unerased_n, + mlp_erased_n, + ) -> tuple[float, float]: + """The difference in the extent to which adding a convolution changes the metric on an erased and an unerased dataset""" + did = (mlp_erased_metric - lenet_erased_metric) - ( + mlp_unerased_metric - lenet_unerased_metric + ) + + se_erased = np.sqrt( + lenet_erased_std ** 2 / lenet_erased_n + + mlp_erased_std ** 2 / mlp_erased_n + ) + se_unerased = np.sqrt( + lenet_unerased_std ** 2 / lenet_unerased_n + + mlp_unerased_std ** 2 / mlp_unerased_n + ) + + se = np.sqrt( + se_erased ** 2 + se_unerased ** 2 + ) + + return did, se + + # Lists to store results + width_results = [] + depth_results = [] + + for dataset in ["cifar10"]: # "cifarnet" + # Width sweep + for width, depth in width_depths: + data = {"lenet": {}, "mlp": {}} + for net in ["lenet", "mlp"]: + for eraser in ["Control", "LEACE", "QLEACE", "ALF-QLEACE"]: + data[net][eraser] = {} + + data[net][eraser]["df"] = df[ + (df["dataset"] == dataset) + & (df["net_id"] == net) + & (df["eraser"] == eraser) + & (df["act"] == "ReLU") + & (df["width"] == width) + & (df["depth"] == depth) + ] + data[net][eraser]["mean"] = data[net][eraser]["df"]["mdl"].mean() + data[net][eraser]["std"] = data[net][eraser]["df"]["mdl"].std() + data[net][eraser]["n"] = 10 # len(data[net][eraser]["df"]["mdl"]) + + # Add fake dataset eraser under Iterative Erasure + data[net]["Iterative Erasure"] = {} + data[net]["Iterative Erasure"]["df"] = df[ + (df["dataset"] == f"fake-{dataset}") + & (df["net_id"] == net) + & (df["eraser"] == "Control") + & (df["act"] == "ReLU") + & (df["width"] == width) + & (df["depth"] == depth) + ] + data[net]["Iterative Erasure"]["mean"] = data[net]["Iterative Erasure"][ + "df" + ]["mdl"].mean() + data[net]["Iterative Erasure"]["std"] = data[net]["Iterative Erasure"][ + "df" + ]["mdl"].std() + data[net]["Iterative Erasure"]["n"] = 10 # len( + # data[net]["Iterative Erasure"]["df"]["mdl"] + # ) + + + for eraser in ["Control", "LEACE", "QLEACE", "ALF-QLEACE"]: + if data['mlp'][eraser]["n"] == 0 or data['lenet'][eraser]["n"] == 0: + print(f"Skipping {eraser} {width} {depth} because n=0") + print('mlp', data['mlp'][eraser]["n"], 'lenet', data['lenet'][eraser]["n"]) + continue + + data['mlp'][eraser]["did"], data['mlp'][eraser]["se"] = diff_of_diffs( + data['lenet']["Control"]["mean"], + data['lenet'][eraser]["mean"], + data['mlp']["Control"]["mean"], + data['mlp'][eraser]["mean"], + data['lenet']["Control"]["std"], + data['lenet'][eraser]["std"], + data['mlp']["Control"]["std"], + data['mlp'][eraser]["std"], + data['lenet']["Control"]["n"], + data['lenet'][eraser]["n"], + data['mlp']["Control"]["n"], + data['mlp'][eraser]["n"], + ) + + data['mlp']["Iterative Erasure"]["did"], data['mlp']["Iterative Erasure"]["se"] = diff_of_diffs( + data['lenet']["Control"]["mean"], + data['lenet']["Iterative Erasure"]["mean"], + data['mlp']["Control"]["mean"], + data['mlp']["Iterative Erasure"]["mean"], + data['lenet']["Control"]["std"], + data['lenet']["Iterative Erasure"]["std"], + data['mlp']["Control"]["std"], + data['mlp']["Iterative Erasure"]["std"], + data['lenet']["Control"]["n"], + data['lenet']["Iterative Erasure"]["n"], + data['mlp']["Control"]["n"], + data['mlp']["Iterative Erasure"]["n"], + ) + + width_results.append({ + "width": width, + "leace_did": data["mlp"]["LEACE"]["did"], + "qleace_did": data["mlp"]["QLEACE"]["did"], + "iterative_erasure_did": data["mlp"]["Iterative Erasure"]["did"], + "alf_qleace_did": data["mlp"]["ALF-QLEACE"]["did"], + "leace_se": data["mlp"]["LEACE"]["se"], + "qleace_se": data["mlp"]["QLEACE"]["se"], + "iterative_erasure_se": data["mlp"]["Iterative Erasure"]["se"], + "alf_qleace_se": data["mlp"]["ALF-QLEACE"]["se"], + }) + + # Depth sweep + for width, depth in widths_depth: + data = {"lenet": {}, "mlp": {}} + + for net in ["lenet", "mlp"]: + for eraser in ["Control", "LEACE", "QLEACE", "ALF-QLEACE"]: + data[net][eraser] = {} + data[net][eraser]["df"] = df[ + (df["dataset"] == dataset) + & (df["net_id"] == net) + & (df["eraser"] == eraser) + & (df["act"] == "ReLU") + & (df["width"] == width) + & (df["depth"] == depth) + ] + data[net][eraser]["mean"] = data[net][eraser]["df"]["mdl"].mean() + data[net][eraser]["std"] = data[net][eraser]["df"]["mdl"].std() + data[net][eraser]["n"] = 10 # len(data[net][eraser]["df"]["mdl"]) + + # Add fake dataset eraser under Iterative Erasure + data[net]["Iterative Erasure"] = {} + data[net]["Iterative Erasure"]["df"] = df[ + (df["dataset"] == f"fake-{dataset}") + & (df["net_id"] == net) + & (df["eraser"] == "Control") + & (df["act"] == "ReLU") + & (df["width"] == width) + & (df["depth"] == depth) + ] + data[net]["Iterative Erasure"]["mean"] = data[net]["Iterative Erasure"][ + "df" + ]["mdl"].mean() + data[net]["Iterative Erasure"]["std"] = data[net]["Iterative Erasure"][ + "df" + ]["mdl"].std() + data[net]["Iterative Erasure"]["n"] = 10 # len( + # data[net]["Iterative Erasure"]["df"]["mdl"] + # ) + + for eraser in ["Control", "LEACE", "QLEACE", "ALF-QLEACE"]: + if data['mlp'][eraser]["n"] == 0: + print(f"Skipping mlp {eraser} {width} {depth} because n=0") + continue + if data['lenet'][eraser]["n"] == 0: + print(f"Skipping lenet {eraser} {width} {depth} because n=0") + continue + + data['mlp'][eraser]["did"], data['mlp'][eraser]["se"] = diff_of_diffs( + data['lenet']["Control"]["mean"], + data['lenet'][eraser]["mean"], + data['mlp']["Control"]["mean"], + data['mlp'][eraser]["mean"], + data['lenet']["Control"]["std"], + data['lenet'][eraser]["std"], + data['mlp']["Control"]["std"], + data['mlp'][eraser]["std"], + data['lenet']["Control"]["n"], + data['lenet'][eraser]["n"], + data['mlp']["Control"]["n"], + data['mlp'][eraser]["n"], + ) + + data['mlp']["Iterative Erasure"]["did"], data['mlp']["Iterative Erasure"]["se"] = diff_of_diffs( + data['lenet']["Control"]["mean"], + data['lenet']["Iterative Erasure"]["mean"], + data['mlp']["Control"]["mean"], + data['mlp']["Iterative Erasure"]["mean"], + data['lenet']["Control"]["std"], + data['lenet']["Iterative Erasure"]["std"], + data['mlp']["Control"]["std"], + data['mlp']["Iterative Erasure"]["std"], + data['lenet']["Control"]["n"], + data['lenet']["Iterative Erasure"]["n"], + data['mlp']["Control"]["n"], + data['mlp']["Iterative Erasure"]["n"], + ) + + depth_results.append( + { + "depth": depth, + "leace_did": data["mlp"]["LEACE"]["did"] if 'did' in data["mlp"]["LEACE"] else None, + "qleace_did": data["mlp"]["QLEACE"]["did"] if 'did' in data["mlp"]["QLEACE"] else None, + "iterative_erasure_did": data["mlp"]["Iterative Erasure"][ + "did" + ] if 'did' in data["mlp"]["Iterative Erasure"] else None, + "alf_qleace_did": data["mlp"]["ALF-QLEACE"]["did"], + "leace_se": data["mlp"]["LEACE"]["se"] if 'se' in data["mlp"]["LEACE"] else None, + "qleace_se": data["mlp"]["QLEACE"]["se"] if 'se' in data["mlp"]["QLEACE"] else None, + "iterative_erasure_se": data["mlp"][ + "Iterative Erasure" + ]["se"] if 'se' in data["mlp"]["Iterative Erasure"] else None, + "alf_qleace_se": data["mlp"]["ALF-QLEACE"][ + "se" + ], + } + ) + + width_df = pd.DataFrame(width_results) + depth_df = pd.DataFrame(depth_results) + + fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.04) + + # Width subplot (left) + for method_idx, (method, name) in enumerate([ + ("qleace", "QLEACE"), ("iterative_erasure", "Iterative Erasure"), + ("alf_qleace", "ALF-QLEACE"),("leace", "LEACE") + ]): + width_df = width_df.sort_values('width') + + fig.add_trace( + go.Scatter( + x=width_df["width"], + y=width_df[f"{method}_did"], + line_color=px.colors.qualitative.Plotly[method_idx], + name=name + ), + row=1, + col=1, + ) + + x = width_df["width"].tolist() + width_df["width"].tolist()[::-1] + y = (width_df[f"{method}_did"] + width_df[f"{method}_se"]).tolist() + (width_df[f"{method}_did"] - width_df[f"{method}_se"]).tolist()[::-1] + + fig.add_trace( + go.Scatter( + x=x, + y=y, + fill='toself', + fillcolor=px.colors.qualitative.Plotly[method_idx], + opacity=0.1, + line=dict(color='rgba(255,255,255,0)'), + showlegend=False, + name=f"{name} error band", + ), + row=1, + col=1, + ) + + # Depth subplot (right) + for method_idx, (method, name) in enumerate([ + ("qleace", "QLEACE"), ("iterative_erasure", "Iterative Erasure"), + ("alf_qleace", "ALF-QLEACE"),("leace", "LEACE") + ]): + depth_df = depth_df.sort_values('depth') + + fig.add_trace( + go.Scatter( + x=depth_df["depth"], + y=depth_df[f"{method}_did"], + line_color=px.colors.qualitative.Plotly[method_idx], + name=name, + showlegend=False, + ), + row=1, + col=2, + ) + + x = depth_df["depth"].tolist() + depth_df["depth"].tolist()[::-1] + y = (depth_df[f"{method}_did"] + depth_df[f"{method}_se"]).tolist() + (depth_df[f"{method}_did"] - depth_df[f"{method}_se"]).tolist()[::-1] + + # Error bands + fig.add_trace( + go.Scatter( + x=x, + y=y, + fill='toself', + fillcolor=px.colors.qualitative.Plotly[method_idx], + opacity=0.1, + line=dict(color='rgba(255,255,255,0)'), + showlegend=False, + name=f"{name} error band" + ), + row=1, + col=2 + ) + + + # Update layout + fig.update_layout( + height=350, + width=1000, + legend=dict( + yanchor="middle", + y=0.66, + xanchor="left", + x=0.55, # Position legend just outside the right edge of plots + bgcolor='rgba(255,255,255,0.7)' + ), + margin=dict(l=20, r=20, t=30, b=50), + ) + + fig.update_xaxes( + title_text="Width", + type="log", + tickvals=[ + 2**i + for i in range( + int(np.log2(min(width_df["width"]))), + int(np.log2(max(width_df["width"]))) + 1, + ) + ], + ticktext=[ + f"2{i}" + for i in range( + int(np.log2(min(width_df["width"]))), + int(np.log2(max(width_df["width"]))) + 1, + ) + ], + row=1, + col=1, + ) + fig.update_xaxes( + title_text="Depth", + type="log", + tickvals=[ + 2**i + for i in range( + int(np.log2(min(depth_df["depth"]))), + int(np.log2(max(depth_df["depth"]))) + 1, + ) + ], + ticktext=[ + f"2{i}" + for i in range( + int(np.log2(min(depth_df["depth"]))), + int(np.log2(max(depth_df["depth"]))) + 1, + ) + ], + row=1, + col=2, + ) + + fig.update_yaxes( + title_text="Difference in differences", + range=[ + 0, + max(width_df["leace_did"].max(), width_df["qleace_did"].max()) * 1.1, + ], + row=1, + col=1, + ) + + fig.update_yaxes( + range=[ + 0, + max(width_df["leace_did"].max(), width_df["qleace_did"].max()) * 1.1, + ], + showticklabels=False, + row=1, + col=2, + ) + + fig.write_image(out / f"combined_did{'_' + tag if tag else ''}_{dataset}.pdf") + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument( + "--data", + type=Path, + default=Path("/mnt/ssd-1/lucia/24-11-21"), + help="Path to the directory containing .pth files.", + ) + parser.add_argument("--out", type=Path, default="data/images/sweep_plots") + parser.add_argument("--tag", type=str, default="") + args = parser.parse_args() + + df = load_sweep_data(args.data) + + analyze_conv_gain(df, args.out, args.tag) diff --git a/experiments/plot/plot_loss.py b/experiments/plot/plot_loss.py new file mode 100644 index 0000000..d938192 --- /dev/null +++ b/experiments/plot/plot_loss.py @@ -0,0 +1,194 @@ +from argparse import ArgumentParser +from pathlib import Path + +import wandb +import pandas as pd +import numpy as np +import plotly.express as px +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from experiments.sweep_eraser import sweep_params +from experiments.scrape_wandb import scrape_data, DISPLAY_NAMES + +import plotly.io as pio + +pio.kaleido.scope.mathjax = None # https://github.com/plotly/plotly.py/issues/3469 + + +def plot_data(df: pd.DataFrame, out: Path, dataset: str): + """Create plots for each network and activation function combination, with different erasers as lines on the same plot.""" + out.mkdir(exist_ok=True) + + df = df[df["dataset"] == dataset] + + # Colors for different erasers + colors = px.colors.qualitative.Plotly + ordered_erasers = ["Control", "LEACE", "QLEACE", "ALF-QLEACE"] + + # for net_id in ["mlp", "lenet", "resmlp"]: + for net_id in df["net_id"].unique(): + net = DISPLAY_NAMES[net_id] + + # Get each activation function used to train this network + net_acts = df[df["net"] == net]["act"].unique() + + reference_width = sweep_params[net_id]["mup_width"] + reference_depth = sweep_params[net_id]["mup_depth"] + width_depths = [ + (width, reference_depth) for width in sweep_params[net_id]["widths"] + ] + widths_depth = [ + (reference_width, depth) for depth in sweep_params[net_id]["depths"] + ] + + # Create separate plot for each activation function + def interleave(list1, list2) -> list: + from itertools import chain + return list(chain.from_iterable(zip(list1, list2))) + list1[len(list2):] + list2[len(list1):] + + + for act in net_acts: + num_rows = max(len(width_depths), len(widths_depth)) + fig = make_subplots( + rows=num_rows, + cols=2, + subplot_titles=[f"Width={w}, Depth={d}" for w, d in interleave(width_depths, widths_depth)], + vertical_spacing=0.03, + row_heights=[400] * num_rows, + ) + + fig.update_layout( + title=f"Loss over 5 seeds ({net}, {act})", + height=280 * num_rows, + width=1200, + showlegend=True, + legend=dict( + title="Eraser type", + yanchor="top", + y=0.99, + xanchor="right", + x=0.99, + ), + ) + + # Match y-axes across subplots + fig.update_yaxes(matches="y1") + + for col, item in enumerate([width_depths, widths_depth], 1): + for row, (width, depth) in enumerate(item, 1): + fig.update_yaxes(title_text="Loss (bits per sample)", row=row, col=col) + + if row == len(width_depths): + fig.update_xaxes(title_text="Epoch", row=row, col=col) + else: + fig.update_xaxes(showticklabels=False, row=row, col=col) + + net_df = df[df["net"] == net] + fig.update_xaxes( + type="log", + row=row, + col=col, + tickvals=[ + 2**i + for i in range( + int(np.log2(min(net_df["step"]))), + int(np.log2(max(net_df["step"]))) + 1, + ) + ], + ticktext=[ + f"2{i}" + for i in range( + int(np.log2(min(net_df["step"]))), + int(np.log2(max(net_df["step"]))) + 1, + ) + ], + ) + + # Plot all erasers for this configuration + for eraser_idx, eraser in enumerate(ordered_erasers): + data = df[ + (df["eraser"] == eraser) & + (df["act"] == act) & + (df["net"] == net) & + (df['width'] == width) & + (df['depth'] == depth) + ] + data = data.sort_values("step") + + mean_data = data.groupby(["step"])["loss"].agg(["mean", "std"]).reset_index() + mean_data = mean_data.sort_values("step") + + # Plot individual runs as transparent lines + for seed in data["seed"].unique(): + seed_data = data[data["seed"] == seed] + fig.add_trace( + go.Scatter( + x=seed_data["step"], + y=seed_data["loss"], + mode="lines", + marker=dict(color=colors[eraser_idx], size=5), + opacity=0.3, + name=f"{eraser} (seeds)", + showlegend=False, + legendgroup=eraser, + ), + row=row, + col=col, + ) + # fig.add_trace( + # go.Scatter( + # x=data["step"], + # y=data["loss"], + # mode="lines+markers", + # marker=dict(color=colors[eraser_idx], size=5, opacity=0.3), + # name=f"{eraser} (seeds)", + # showlegend=False, + # legendgroup=eraser, + # ), + # row=row, + # col=col, + # ) + + # Plot mean as a line + fig.add_trace( + go.Scatter( + x=mean_data["step"], + y=mean_data["mean"], + mode="lines+markers", + line=dict(width=2), + name=f"{eraser}", + legendgroup=eraser, + showlegend=row == 1 and col == 1, + marker=dict(color=colors[eraser_idx]), + ), + row=row, + col=col, + ) + + # Save plot for this activation function + fig.write_image(out / f"{net}_{act}_{dataset}_loss.pdf", format="pdf") + +def parse_args(): + parser = ArgumentParser() + parser.add_argument("--out", type=str, default="images/sweep_plots") + parser.add_argument("--data", type=str, default="loss_curve.csv") + parser.add_argument("--dataset", type=str, default="cifar10") + parser.add_argument("--scrape", action="store_true") + return parser.parse_args() + +if __name__ == '__main__': + args = parse_args() + + data_path = Path('data') + + data = data_path / f'{args.data}' + out = data_path / args.out + + if args.scrape: + scrape_data(data) + + df = pd.read_csv(data) + df = df[df["dataset"] == args.dataset] + + plot_data(df, out, args.dataset) \ No newline at end of file diff --git a/experiments/plot/plot_mdl.py b/experiments/plot/plot_mdl.py new file mode 100644 index 0000000..9c9cf50 --- /dev/null +++ b/experiments/plot/plot_mdl.py @@ -0,0 +1,236 @@ +import pandas as pd +from pathlib import Path +from argparse import ArgumentParser +import torch +import plotly.express as px +from plotly.subplots import make_subplots +import numpy as np +import plotly.io as pio +import plotly.graph_objects as go + +from experiments.scrape_wandb import DISPLAY_NAMES +from experiments.sweep_eraser import sweep_params + +pio.kaleido.scope.mathjax = None + + +def load_sweep_data(data_path: Path) -> pd.DataFrame: + records = [] + for file in data_path.glob("*.pth"): + stem = file.stem.replace("alf_qleace", "alf-qleace") + + try: + net, act, width, depth, eraser, directory, dataset = stem.split("_") + except ValueError: + print(f"Skipping malformed filename: {stem}") + continue + + width = int(width.split("=")[1]) + depth = int(depth.split("=")[1]) + + try: + data = torch.load(file, weights_only=False) + except Exception as e: + print(f"Error loading data: {file}, {e}") + continue + + for seed, result in enumerate(data): + # Handle nested list structure + while isinstance(result, list): + result = result[0] + + base_dataset = dataset.replace('fake-leace-', '') + base_dataset = base_dataset.replace('fake-', '') + + if 'fake-leace' in dataset: + eraser_name = "LEACE and Iterative Erasure" + elif 'fake' in dataset: + eraser_name = "Iterative Erasure" + else: + eraser_name = DISPLAY_NAMES[eraser] + + records.append( + { + "dataset": base_dataset, + "net_id": net, + "net": DISPLAY_NAMES[net], + "act": DISPLAY_NAMES[act], + "eraser": eraser_name, + "width": width, + "depth": depth, + "seed": seed, + "mdl": result.mdl, + "ce_curve": result.ce_curve, + "sample_sizes": result.sample_sizes, + "total_trials": result.total_trials, + } + ) + + return pd.DataFrame(records) + + +def create_plots(df: pd.DataFrame, output_dir: Path, dataset: str): + output_dir.mkdir(exist_ok=True, parents=True) + colors = px.colors.qualitative.Plotly + + ordered_erasers = [ + e + for e in ["Control", "LEACE", "QLEACE", "ALF-QLEACE", "Iterative Erasure"] + if e in df["eraser"].unique() + ] + + df = df[df["dataset"] == dataset].sort_values(["depth", "width"]) + + net_ids = ["mlp", "lenet", "resmlp"] + for net_id in net_ids: + # for net_id in df["net_id"].unique(): + net = DISPLAY_NAMES[net_id] + reference_width = sweep_params[net_id]["mup_width"] + reference_depth = sweep_params[net_id]["mup_depth"] + ordered_acts = ["ReLU", "GELU", "SwiGLU"] if net == "MLP" else ["ReLU"] + + fig = make_subplots( + rows=len(ordered_erasers), + cols=2, + subplot_titles=sum(zip(ordered_erasers, ordered_erasers), ()), + vertical_spacing=0.05, + horizontal_spacing=0.05, + row_heights=[400] * len(ordered_erasers), + shared_yaxes="rows", + ) + + fig.update_layout( + title=f"Minimum description length over 5 seeds ({net})", + height=300 * len(ordered_erasers), + width=1200, + showlegend=len(ordered_acts) > 1, + legend=( + dict( + title="Activation function", + yanchor="top", + y=0.95, + xanchor="right", + x=0.95, + ) + if len(ordered_acts) > 1 + else None + ), + ) + + net_df = df[df["net"] == net] + + for row, eraser in enumerate(ordered_erasers, 1): + # Set up axes + fig.update_yaxes( + title_text="MDL (bits per sample)", # if col == 1 else "", + showticklabels=True, # (col == 1), + matches="y1", + row=row, + col=1, + ) + fig.update_yaxes(showticklabels=False, row=row, col=2) + + for col, param in enumerate(["depth", "width"], 1): + fig.update_xaxes( + title_text=param.title() if row == len(ordered_erasers) else "", + showticklabels=(row == len(ordered_erasers)), + type="log", + tickvals=[ + 2**i + for i in range( + int(np.log2(min(net_df[param]))), + int(np.log2(max(net_df[param]))) + 1, + ) + ], + ticktext=[ + f"2{i}" + for i in range( + int(np.log2(min(net_df[param]))), + int(np.log2(max(net_df[param]))) + 1, + ) + ], + row=row, + col=col, + ) + + # Plot data for each activation function + for act_idx, act in enumerate(ordered_acts): + data = df[ + (df["eraser"] == eraser) & (df["act"] == act) & (df["net"] == net) + ] + if data.empty: + continue + + mean_data = ( + data.groupby(["width", "depth"])["mdl"] + .agg(["mean", "std"]) + .reset_index() + ) + + # Plot depth data + depth_data = data[data["width"] == reference_width] + mean_depth = mean_data[mean_data["width"] == reference_width] + + for col, (ref_val, param_data, mean_param) in enumerate( + [ + (reference_width, depth_data, mean_depth), + ( + reference_depth, + data[data["depth"] == reference_depth], + mean_data[mean_data["depth"] == reference_depth], + ), + ], + 1, + ): + param = "depth" if col == 1 else "width" + + # Plot individual points + fig.add_trace( + go.Scatter( + x=param_data[param], + y=param_data["mdl"], + mode="markers", + marker=dict(color=colors[act_idx], size=5, opacity=0.3), + showlegend=False, + ), + row=row, + col=col, + ) + + # Plot mean line + fig.add_trace( + go.Scatter( + x=mean_param[param], + y=mean_param["mean"], + mode="lines+markers", + line=dict(width=2), + marker=dict(color=colors[act_idx]), + name=act, + showlegend=(row == 1 and col == 1), + ), + row=row, + col=col, + ) + + fig.write_image(output_dir / f"{net}_MDL_{dataset}.pdf", format="pdf") + + +def main(): + parser = ArgumentParser() + parser.add_argument("--data", type=Path, default=Path("24-11-21")) + parser.add_argument("--dataset", type=str, default="cifar10") + parser.add_argument("--out", type=Path, default=Path("data/images/sweep_plots")) + args = parser.parse_args() + + if args.dataset == "cifar10": + assert "cifarnet" not in args.data.name + + print("Loading disk data into dataframe...") + df = load_sweep_data(args.data) + + print("Creating plots...") + create_plots(df, args.out, args.dataset) + + +if __name__ == "__main__": + main() diff --git a/experiments/plot/plot_mdl_combined.py b/experiments/plot/plot_mdl_combined.py new file mode 100644 index 0000000..ce3d09e --- /dev/null +++ b/experiments/plot/plot_mdl_combined.py @@ -0,0 +1,166 @@ +import pandas as pd +from argparse import ArgumentParser +from pathlib import Path +import torch +import plotly.graph_objects as go +from plotly.subplots import make_subplots +import numpy as np +import plotly.io as pio +import plotly.express as px + + +from experiments.plot.plot_mdl import DISPLAY_NAMES, load_sweep_data +from experiments.sweep_eraser import sweep_params + +def create_plots(df: pd.DataFrame, output_dir: Path, dataset: str): + output_dir.mkdir(exist_ok=True, parents=True) + colors = px.colors.qualitative.Plotly + + ordered_erasers = [ + e + for e in ["Control", "LEACE", "QLEACE", "ALF-QLEACE", "Iterative Erasure"] + if e in df["eraser"].unique() + ] + + df = df[df["dataset"] == dataset].sort_values(["depth", "width"]) + + for net_id in df["net_id"].unique(): + net = DISPLAY_NAMES[net_id] + reference_width = sweep_params[net_id]["mup_width"] + reference_depth = sweep_params[net_id]["mup_depth"] + + fig = make_subplots( + rows=1, + cols=2, + subplot_titles=["Depth sweep", "Width sweep"], + horizontal_spacing=0.1, + ) + + fig.update_layout( + title=f"Minimum description length over 5 seeds ({net})", + height=400, + width=1200, + showlegend=True, + legend=dict( + title="Eraser type", + yanchor="top", + y=0.95, + xanchor="right", + x=0.95, + ), + ) + + # Set up axes + for col in [1, 2]: + fig.update_yaxes( + title_text="MDL (bits per sample)" if col == 1 else "", + showticklabels=True, + row=1, + col=col, + ) + + # Set up x-axes + for col, param in enumerate(["depth", "width"], 1): + param_data = df[param].unique() + fig.update_xaxes( + title_text=param.title(), + type="log", + tickvals=[ + 2**i + for i in range( + int(np.log2(min(param_data))), + int(np.log2(max(param_data))) + 1, + ) + ], + ticktext=[ + f"2{i}" + for i in range( + int(np.log2(min(param_data))), + int(np.log2(max(param_data))) + 1, + ) + ], + row=1, + col=col, + ) + + net_df = df[(df["net"] == net) & (df["act"] == "ReLU")] + + # Plot data for each eraser type + for eraser_idx, eraser in enumerate(ordered_erasers): + data = net_df[net_df["eraser"] == eraser] + if data.empty: + continue + + mean_data = ( + data.groupby(["width", "depth"])["mdl"] + .agg(["mean", "std"]) + .reset_index() + ) + + # Plot depth sweep (fixed width) + depth_data = data[data["width"] == reference_width] + mean_depth = mean_data[mean_data["width"] == reference_width] + + # Plot width sweep (fixed depth) + width_data = data[data["depth"] == reference_depth] + mean_width = mean_data[mean_data["depth"] == reference_depth] + + for col, (param_data, mean_param) in enumerate( + [ + (depth_data, mean_depth), + (width_data, mean_width), + ], + 1, + ): + param = "depth" if col == 1 else "width" + + # Plot individual points + fig.add_trace( + go.Scatter( + x=param_data[param], + y=param_data["mdl"], + mode="markers", + marker=dict(color=colors[eraser_idx], size=5, opacity=0.3), + showlegend=False, + ), + row=1, + col=col, + ) + + # Plot mean line + fig.add_trace( + go.Scatter( + x=mean_param[param], + y=mean_param["mean"], + mode="lines+markers", + line=dict(width=2), + marker=dict(color=colors[eraser_idx]), + name=eraser, + showlegend=(col == 1), + ), + row=1, + col=col, + ) + + fig.write_image(output_dir / f"{net}_MDL_{dataset}.pdf", format="pdf") + + +def main(): + parser = ArgumentParser() + parser.add_argument("--data", type=Path, default=Path("/mnt/ssd-1/lucia/24-11-21")) + parser.add_argument("--dataset", type=str, default="cifar10") + parser.add_argument("--out", type=Path, default=Path("data/images/sweep_plots")) + args = parser.parse_args() + + if args.dataset == "cifar10": + assert "cifarnet" not in args.data.name + + print("Loading disk data into dataframe...") + df = load_sweep_data(args.data) + + print("Creating plots...") + create_plots(df, args.out, args.dataset) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/experiments/plot/plot_sample_images.py b/experiments/plot/plot_sample_images.py new file mode 100644 index 0000000..47be7d8 --- /dev/null +++ b/experiments/plot/plot_sample_images.py @@ -0,0 +1,530 @@ +from pathlib import Path +from typing import Literal +from dataclasses import dataclass +from functools import partial + +import torch.nn.functional as F +import torchvision.utils as vutils +import matplotlib.pyplot as plt +from simple_parsing import ArgumentParser +import lovely_tensors as lt +import numpy as np +import pandas as pd +import torch +import torchvision.utils as vutils +from torch import Tensor +from datasets import load_from_disk +from concept_erasure import groupby +from torchvision.transforms.v2.functional import to_dtype, to_image +from experiments.cli import ( + get_cifar10, + get_cifarnet, + get_fake_cifarnet, + get_svhn, + IdentityEraser, + load_eraser, + get_fake_svhn, + get_fake_cifar10 +) + +plt.rcParams['font.family'] = 'DejaVu Serif' +plt.rcParams['font.weight'] = 'bold' + + +@dataclass +class Args: + # General settings + out: str = "data/images" + + # Dataset options + method: Literal["leace", "orth", "none"] = "leace" + shrinkage: bool = False + normalize: bool = False + post_erase_normalize: bool = False + alf_qleace_target: float = 0.9 + + # Runtime flags + debug: bool = False + nocache: bool = False + nowritecache: bool = False + save: bool = False + overwrite: bool = False + trial: bool = False # Run a single trial with all data + wandb_run_id: str | None = None + + +# def fix_cache(args, device): +# state_path = Path("data") / "erasers_cache" / "state.pth" +# state_path.parent.mkdir(parents=True, exist_ok=True) +# new_state = ( +# {} if not state_path.exists() else torch.load(state_path, weights_only=False) +# ) + +# # leace_cache_key = get_cache_key('cifarnet', 'leace', dtype, args.method, args.shrinkage, 0.9) +# # new_state[leace_cache_key] = leace_eraser_cifarnet.to('cpu') +# # qleace_cache_key = get_cache_key('cifarnet', 'qleace', dtype, args.method, args.shrinkage, 0.9) +# # new_state[qleace_cache_key] = qleace_eraser_cifarnet.to('cpu') +# # alf_qleace_99_cache_key = get_cache_key('cifarnet', 'alf_qleace', dtype, args.method, args.shrinkage, 0.99) +# # new_state[alf_qleace_99_cache_key] = alf_qleace_99_cifarnet.to('cpu') + +# torch.save(new_state, state_path) + + +def main(): + parser = ArgumentParser() + parser.add_arguments(Args, dest="args") + args = parser.parse_args().args + + lt.monkey_patch() + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + Path(args.out).mkdir(parents=True, exist_ok=True) + + plot_all_erasers_cifarnet(args, device) + # print("Plotting erasers") + # plot_all_erasers_cifar10(args, device) + # print("Plotting alf qleace") + # plot_cifarnet_alf_qleace(args, device) + # print("Plotting datasets w iterative erasure") + # plot_all_datasets_iterative_erasure(args, device) + + +def plot_cifarnet_alf_qleace(args, device): + # Plot CIFARNet + (X_train, Y_train, X_val, Y_val, k, X, Y) = get_cifarnet(shuffle=False) + num_features = X.shape[1] * X.shape[2] * X.shape[3] + image_side = X.shape[2] + dtype = torch.float32 + + load_example_eraser = partial( + load_eraser, + dataset_str="cifarnet", + dtype=dtype, + shrinkage=args.shrinkage, + X_train=X_train, + Y_train=Y_train, + num_features=num_features, + k=k, + nowritecache=args.nowritecache, + nocache=args.nocache, + ) + alf_qleace_90_cifarnet = load_example_eraser( + "alf_qleace", alf_qleace_target=0.9, method="leace" + ).to(device) + alf_qleace_99_cifarnet = load_example_eraser( + "alf_qleace", alf_qleace_target=0.99, method=args.method + ).to(device) + + # Get CIFARNet sample images and apply different erasers + sample_image = X_train[1:2].to(device) + sample_label = Y_train[1:2].to(device) + sample_images = { + "Original": sample_image.squeeze(), + "ALF-QLEACE-90": alf_qleace_90_cifarnet(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "ALF-QLEACE-99": alf_qleace_99_cifarnet(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + } + + grid = vutils.make_grid( + [ + sample_images["Original"], + sample_images["ALF-QLEACE-90"], + sample_images["ALF-QLEACE-99"], + ], + nrow=3, # 3 images per row + padding=4, # Increase padding between images + normalize=True, + pad_value=1, # Use white padding (1 = white, 0 = black) + ) + + # Convert to numpy and transpose to correct format (H,W,C) + grid_img = grid.cpu().permute(1, 2, 0).numpy() + + fig, ax = plt.subplots(figsize=(15, 6), facecolor="white") + ax.imshow(grid_img) + ax.axis("off") + + # Calculate positions for captions + img_width = grid_img.shape[1] / 3 # 3 images in the grid + captions = [ + "Original", + "ALF-QLEACE (90%)", + "ALF-QLEACE (99%)", + ] + + for i, caption in enumerate(captions): + # Position text under each image + x_pos = (i + 0.5) * img_width # Center of each image + y_pos = grid_img.shape[0] # Below the image + + ax.text(x_pos, y_pos, caption, + fontsize=10, + family='DejaVu Serif', + ha='center', + va='top') + + plt.savefig( + "data/images/sample_alf_qleace_spectrum.pdf", + bbox_inches="tight", # Remove excess white space + facecolor="white", + dpi=300, + pad_inches=0.1 + ) # Ensure white background in saved file + + +# Get erased original CIFAR10 dataset (not HF version modified and uploaded to the HF hub) +def get_fake_cifar10_disk(): + train = load_from_disk("data/eraser-order-cifar10")["train"] + X = torch.stack( + [ + to_dtype(to_image(img), dtype=torch.float32, scale=True) + for img in train["image"] + ] + ) + Y = torch.tensor(train["label"]) + + # Split train and validation + val_size = 1024 + X_train, X_val = X[:-val_size], X[-val_size:] + Y_train, Y_val = Y[:-val_size], Y[-val_size:] + return X_train, Y_train, None, None, 0, None, None + +def plot_all_erasers_cifar10(args, device): + (X_train, Y_train, X_val, Y_val, k, X, Y) = get_cifar10(device, shuffle=False) + num_features = X.shape[1] * X.shape[2] * X.shape[3] + image_side = X.shape[2] + dtype = torch.float32 + + # state_path = Path("data") / "erasers_cache" / "state.pth" + # state_path.parent.mkdir(parents=True, exist_ok=True) + # new_state = {} if not state_path.exists() else torch.load(state_path, weights_only=False) + + # old_state = torch.load(Path("data") / "erasers_cache" / f"cifar10_{dtype}_state.pth", weights_only=False) + # for eraser in ['leace', 'qleace']: + # cache_key = get_cache_key('cifar10', eraser, dtype, args.method, args.shrinkage, 0.99) + # if cache_key not in new_state and eraser in old_state: + # new_state[cache_key] = old_state[eraser].to("cpu") + + # torch.save(new_state, state_path) + + # Load erasers + load_example_eraser = partial( + load_eraser, + dataset_str="cifar10", + dtype=dtype, + shrinkage=args.shrinkage, + X_train=X_train, + Y_train=Y_train, + num_features=num_features, + k=k, + nowritecache=args.nowritecache, + random_erase_dims=25 + ) + + leace_eraser_cifar10 = load_example_eraser( + "leace", alf_qleace_target=-1, method=args.method, nocache=args.nocache + ).to(device) + qleace_eraser_cifar10 = load_example_eraser( + "qleace", alf_qleace_target=-1, method=args.method, nocache=args.nocache + ).to(device) + alf_qleace_90_cifar10 = load_example_eraser( + "alf_qleace", alf_qleace_target=0.90, method=args.method, nocache=args.nocache + ).to(device) + alf_qleace_99_cifar10 = load_example_eraser( + "alf_qleace", alf_qleace_target=0.99, method=args.method, nocache=args.nocache + ).to(device) + random_eraser_cifar10 = load_example_eraser( + "random", alf_qleace_target=-1, method=args.method, nocache=args.nocache + ).to(device) + + sample_image = X_train[3:4].to(device) + sample_label = Y_train[3:4].to(device) + + # Erased CIFAR-10 on HF hub originates in the HF dataset ordering. Using the original dataset to get a matched image. + fake_cifar10_images, fake_cifar10_labels, _, _, _, _, _ = get_fake_cifar10_disk() + + # Get first item in fake_cifar10_images with a corresponding fake_cifar10_labels label + fake_cifar10_image = fake_cifar10_images[ + fake_cifar10_labels == sample_label.item() + ][0] + + sample_images = { + "Original": sample_image.squeeze(), + "Random": random_eraser_cifar10(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "LEACE": leace_eraser_cifar10(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "QLEACE": qleace_eraser_cifar10(sample_image.flatten(1), sample_label) + .reshape_as(sample_image) + .squeeze(), + "ALF-QLEACE-90": alf_qleace_90_cifar10(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "ALF-QLEACE-99": alf_qleace_99_cifar10(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "Iterative-Erasure": fake_cifar10_image.to(device), + } + + grid = vutils.make_grid( + [ + sample_images["Original"], + sample_images["LEACE"], + sample_images["QLEACE"], + sample_images["ALF-QLEACE-90"], + sample_images["Iterative-Erasure"], + sample_images["Random"], + ], + nrow=6, + padding=4, + normalize=True, + value_range=(0, 1), + pad_value=1, # Use white padding (1 = white, 0 = black) + ) + + # Convert to numpy and transpose to correct format (H,W,C) + grid_img = grid.cpu().permute(1, 2, 0).numpy() + + fig, ax = plt.subplots(figsize=(15, 6), facecolor="white") + ax.imshow(grid_img) + ax.axis("off") + + # Calculate positions for captions + img_width = grid_img.shape[1] / 6 # 6 images in the grid + captions = [ + "Original", + "LEACE", + "QLEACE", + "ALF-QLEACE", + "Iterative Erasure", + "Random Erasure", + ] + + for i, caption in enumerate(captions): + # Position text under each image + x_pos = (i + 0.5) * img_width # Center of each image + y_pos = grid_img.shape[0] # Below the image + + ax.text(x_pos, y_pos, caption, + fontsize=10, + family='DejaVu Serif', + ha='center', + va='top') + + # plt.subplots_adjust(bottom=0.02, top=) + + plt.savefig( + "data/images/eraser_comparison_cifar10.pdf", + bbox_inches="tight", + facecolor="white", + dpi=300, + pad_inches=0.1 + ) + plt.close() + + +def plot_all_erasers_cifarnet(args, device): + (X_train, Y_train, X_val, Y_val, k, X, Y) = get_cifarnet(shuffle=False) + num_features = X.shape[1] * X.shape[2] * X.shape[3] + image_side = X.shape[2] + dtype = torch.float32 + + # Load erasers + load_example_eraser = partial( + load_eraser, + dataset_str="cifarnet", + dtype=dtype, + X_train=X_train, + Y_train=Y_train, + num_features=num_features, + k=k, + nowritecache=args.nowritecache, + # Rank 13 for 90%, rank 238 for 99%, rank 2000+ for 99.9% for method = leace + # Rank 112 for 90% for method = orth + # Rank 13 for 90% LEACE with shrinkage + random_erase_dims=13 + ) + + leace_eraser_cifarnet = load_example_eraser( + "leace", alf_qleace_target=-1, method=args.method, nocache=args.nocache, shrinkage=args.shrinkage + ).to(device) + qleace_eraser_cifarnet = load_example_eraser( + "qleace", alf_qleace_target=-1, method=args.method, nocache=args.nocache, shrinkage=args.shrinkage + ).to(device) + alf_qleace_90_cifarnet = load_example_eraser( + "alf_qleace", alf_qleace_target=0.90, method=args.method, nocache=args.nocache, shrinkage=args.shrinkage + ).to(device) + alf_qleace_99_cifarnet = load_example_eraser( + "alf_qleace", alf_qleace_target=0.99, method=args.method, nocache=args.nocache, shrinkage=args.shrinkage + ).to(device) + random_eraser_cifarnet = load_example_eraser( + "random", alf_qleace_target=-1, method=args.method, nocache=args.nocache, shrinkage=args.shrinkage + ).to(device) + + sample_image = X_train[:1].to(device) + sample_label = Y_train[:1].to(device) + + # Erased CIFAR-net on HF hub originates in the HF dataset ordering. Using the original dataset to get a matched image. + fake_cifarnet_images, fake_cifarnet_labels, _, _, _, _, _ = get_fake_cifarnet(shuffle=False) + + # Get first item in fake_cifarnet_images with a corresponding fake_cifarnet_labels label of 6 + fake_cifarnet_image_6 = fake_cifarnet_images[ + fake_cifarnet_labels == sample_label.item() + ][0] + + sample_images = { + "Original": sample_image.squeeze(), + "Random": random_eraser_cifarnet(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "LEACE": leace_eraser_cifarnet(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "QLEACE": qleace_eraser_cifarnet(sample_image.flatten(1), sample_label) + .reshape_as(sample_image) + .squeeze(), + "ALF-QLEACE-90": alf_qleace_90_cifarnet(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "ALF-QLEACE-99": alf_qleace_99_cifarnet(sample_image.flatten(1)) + .reshape_as(sample_image) + .squeeze(), + "Iterative-Erasure": fake_cifarnet_image_6.to(device), + } + + grid = vutils.make_grid( + [ + sample_images["Original"], + sample_images["LEACE"].clip(0, 1), + sample_images["QLEACE"].clip(0, 1), + sample_images["ALF-QLEACE-90"].clip(0, 1), # Contains very negative numbers, maybe needs to be rescaled using a bias term? + sample_images["Iterative-Erasure"].clip(0, 1), + sample_images["Random"].clip(0, 1), + ], + nrow=6, + padding=4, + normalize=False, + value_range=(0, 1), + pad_value=1, # Use white padding (1 = white, 0 = black) + ) + + # Convert to numpy and transpose to correct format (H,W,C) + grid_img = grid.cpu().permute(1, 2, 0).numpy() + + fig, ax = plt.subplots(figsize=(15, 6), facecolor="white") + ax.imshow(grid_img) + ax.axis("off") + + # Calculate positions for captions + img_width = grid_img.shape[1] / 6 # 6 images in the grid + captions = [ + "Original", + "LEACE", + "QLEACE", + "ALF-QLEACE", + "Iterative Erasure", + "Random Erasure", + ] + + for i, caption in enumerate(captions): + # Position text under each image + x_pos = (i + 0.5) * img_width # Center of each image + y_pos = grid_img.shape[0] # Below the image + + ax.text(x_pos, y_pos, caption, + fontsize=10, + family='DejaVu Serif', + ha='center', + va='top') + + # plt.subplots_adjust(bottom=0.02, top=) + + plt.savefig( + "data/images/eraser_comparison_cifarnet.pdf", + bbox_inches="tight", + facecolor="white", + dpi=300, + pad_inches=0.1 + ) + plt.close() + + + +def plot_all_datasets_iterative_erasure(args, device): + target_size = (64, 64) + datasets = ["cifar-10", "cifarnet", "svhn"] + plot_images = [] + samples_per_dataset = 10 # Based on your nrow=10 setting + + for dataset in datasets: + X, Y, _, _, _, _, _ = { + "cifar-10": get_cifar10(device, shuffle=False), + "cifarnet": get_cifarnet(shuffle=False), + "svhn": get_svhn(device, shuffle=False), + }[dataset] + + fake_X, fake_Y, _, _, _, _, _ = { + "cifar-10": get_fake_cifar10_disk(), + "cifarnet": get_fake_cifarnet(shuffle=False), + "svhn": get_fake_svhn(shuffle=False), + }[dataset] + + samples = [] + fake_samples = [] + for y, x in groupby(X, Y): + if len(fake_samples) >= samples_per_dataset: + break + sample = x[0] + fake_sample = fake_X[fake_Y == y][0] + + if sample.shape[-2:] != target_size and ( + sample.shape[-2] < target_size[0] or sample.shape[-1] < target_size[1] + ): + sample = F.interpolate(sample.unsqueeze(0), size=target_size, mode="bicubic", align_corners=False).squeeze() + fake_sample = F.interpolate(fake_sample.unsqueeze(0), size=target_size, mode="bicubic", align_corners=False).squeeze() + + samples.append(sample.cpu()) + fake_samples.append(fake_sample.cpu()) + + plot_images.extend(fake_samples) + + grid = vutils.make_grid(plot_images, nrow=samples_per_dataset, padding=4, normalize=False, pad_value=1) + grid_img = grid.cpu().permute(1, 2, 0).numpy() + + # Create figure with extra space on the left for labels + fig, ax = plt.subplots(figsize=(14, 4), facecolor="white") + + # Display the grid + ax.imshow(grid_img) + + # Add dataset labels + cell_height = grid_img.shape[0] / len(datasets) + for idx, dataset in enumerate(datasets): + y_pos = (idx + 0.5) * cell_height + ax.text(-20, y_pos, dataset.upper(), + horizontalalignment='right', + verticalalignment='center', + family='DejaVu Serif', + rotation=0, + fontsize=10) + + ax.axis("off") + + # Adjust layout to prevent label cutoff + plt.subplots_adjust(left=0.1) + + plt.savefig( + "data/images/iterative_erasure_minimally_changes_dataset.pdf", + bbox_inches="tight", + facecolor="white", + dpi=300, + ) + plt.close() + +if __name__ == "__main__": + main() diff --git a/experiments/polyapprox_mlp.py b/experiments/polyapprox_mlp.py new file mode 100644 index 0000000..e0cc218 --- /dev/null +++ b/experiments/polyapprox_mlp.py @@ -0,0 +1,220 @@ +from pathlib import Path + +from plotly.subplots import make_subplots +import plotly.graph_objects as go +import pandas as pd +import torch +import torch.nn as nn +from torch import Tensor +from polyapprox.ols import ols +from mdl.mlp_probe import MlpProbe +import lovely_tensors as lt +from mup import set_base_shapes +from experiments.cli import get_cifar10 + +lt.monkey_patch() + +lt.monkey_patch() + +class QuadraticModel: + def __init__(self, alpha: Tensor, beta: Tensor, gamma: Tensor, d: int): + self.alpha = alpha + self.beta = beta + self.gamma = gamma + + self.quad_rows, self.quad_cols = torch.tril_indices(d, d) + + def __call__(self, X): + return self.forward(X) + + def forward(self, X): + linear = X @ self.beta + pairs = X[:, self.quad_rows] * X[:, self.quad_cols] + + return self.alpha + linear + pairs @ self.gamma.T + + +class LinearModel: + def __init__(self, alpha: Tensor, beta: Tensor): + self.alpha = alpha + self.beta = beta + + def __call__(self, X): + return self.forward(X) + + def forward(self, X): + return X @ self.beta + self.alpha + + +def calculate_fvu(model: nn.Module, approx_model: QuadraticModel | LinearModel, + data_loader: torch.utils.data.DataLoader, device="cuda") -> float: + """Calculate Fraction of Variance Unexplained""" + all_outputs = [] + all_approx = [] + + for inputs in data_loader: + inputs = inputs.to(device) + model_output = model(inputs) + quad_output = approx_model(inputs) + + all_outputs.extend(model_output) + all_approx.extend(quad_output) + + all_outputs = torch.stack(all_outputs) + all_approx = torch.stack(all_approx) + + # Calculate FVU = mean squared error / variance of true outputs + mse = torch.mean((all_outputs - all_approx) ** 2).item() + var = torch.var(all_outputs).item() + + return mse / var + + +def normalize_cifar10(X, X_train, X_val): + X_flat = X.reshape(X.shape[0], -1) + + mean = X_flat.mean(dim=0, keepdim=True) + X_centered = X_flat - mean + + cov = (X_centered.T @ X_centered) / (X_centered.shape[0] - 1) + + scaling = torch.sqrt(torch.diagonal(cov)) + scaling = torch.where(scaling > 0, scaling, torch.ones_like(scaling)) + + def normalize_data(data: Tensor) -> Tensor: + data_flat = data.reshape(data.shape[0], -1) + data_centered = data_flat - mean + data_normalized = data_centered / scaling + return data_normalized.reshape(data.shape) + + X = normalize_data(X) + X_train = normalize_data(X_train) + X_val = normalize_data(X_val) + + return X, X_train, X_val + + +def prepare_random_data(n_samples: int, input_dim: int) -> torch.utils.data.DataLoader: + """Prepare normally distributed random data""" + X = torch.randn(n_samples, input_dim) + return torch.utils.data.DataLoader(X, batch_size=100, shuffle=True) + + +def plot(ols_results, filename='polyapprox_mlp_fvu'): +# Plot FVU over checkpoints - the final number in each name is the checkpoints + fvu = [] + checkpoint = [] + eraser = [] + for key, value in ols_results.items(): + if value.fvu < -0.01: + print(f"{key} has FVU {value.fvu}. Skipping.") + continue + + fvu.append(value.fvu) + chunks = key[:-4].split("-") + checkpoint.append(int(chunks[-1])) + eraser.append(chunks[0].split(" ")[0]) + + df = pd.DataFrame({"fvu": fvu, "checkpoint": checkpoint, "eraser": eraser}) + df = df.sort_values(by="checkpoint") + + fig = make_subplots(rows=len(df.eraser.unique()), cols=1) + + for row, eraser in enumerate(df.eraser.unique(), start=1): + df_eraser = df[df.eraser == eraser] + fig.add_trace(go.Scatter(x=df_eraser.checkpoint, y=df_eraser.fvu, mode="lines", name=eraser), row=row, col=1) + + fig.update_layout(title="FVU over checkpoints") + fig.write_image(f"data/{filename}.pdf", format="pdf") + +@torch.no_grad() +def main(): + # Load each MLP checkpoint ols + out_path = Path("data/polyapprox_mlp.pth") + ckpts = list(Path("probe-ckpts").glob("*.pth")) + ols_results = {} if not out_path.exists() else torch.load(out_path, weights_only=False) + base_shapes_path = f"mup-mlp-128-1-128.bsh" + probe = MlpProbe( + num_features=32 * 32 * 3, num_classes=10, hidden_size=128, num_layers=1 + ) + + n_samples, input_dim = 10_000, 32 * 32 * 3 + (X_train, Y_train, X_val, Y_val, k, X, Y) = get_cifar10(device="cpu") + X, X_train, X_val = normalize_cifar10(X, X_train, X_val) + + X_d = X.shape[1] * X.shape[2] * X.shape[3] + cifar10_dataloader = torch.utils.data.DataLoader(X_train[:n_samples].flatten(1), batch_size=100, shuffle=True) + + + random_loader = prepare_random_data(n_samples, input_dim) + device = "cuda" + + for ckpt in ckpts: + if 'normalize' not in ckpt.name or 'control' not in ckpt.name or 'relu' not in ckpt.name: + continue + + if ckpt.name in ols_results: + print(f"Skipping {ckpt.name} because it already exists") + continue + + print(f"Processing {ckpt.name}") + + probe.load_state_dict(torch.load(ckpt, weights_only=False)) + set_base_shapes(probe, base_shapes_path, rescale_params=False) + probe.to(device) + + ols_results[ckpt.name] = {} + + ols_results[ckpt.name]['ols'] = ols( + probe.net[0].weight.data.double().cpu().numpy(), + probe.net[0].bias.data.double().cpu().numpy(), + probe.net[2].weight.data.double().cpu().numpy(), + probe.net[2].bias.data.double().cpu().numpy(), + act="relu", + order="quadratic", + ) + + quad_approx = QuadraticModel( + torch.from_numpy(ols_results[ckpt.name]['ols'].alpha).float().to(device), + torch.from_numpy(ols_results[ckpt.name]['ols'].beta).float().to(device), + torch.from_numpy(ols_results[ckpt.name]['ols'].gamma).float().to(device), + d=X_d + ) + + random_fvu = calculate_fvu(probe, quad_approx, random_loader, device) + print(f"FVU for random data: {random_fvu:.4f}") + ols_results[ckpt.name]['random_fvu'] = random_fvu + + cifar_fvu = calculate_fvu(probe, quad_approx, cifar10_dataloader, device) + print(f"FVU for CIFAR-10: {cifar_fvu:.4f}") + ols_results[ckpt.name]['cifar_fvu'] = cifar_fvu + + ols_linear = ols( + probe.net[0].weight.data.double().cpu().numpy(), + probe.net[0].bias.data.double().cpu().numpy(), + probe.net[2].weight.data.double().cpu().numpy(), + probe.net[2].bias.data.double().cpu().numpy(), + act="relu", + order="linear", + # TODO use true data mean and covariance matrix on probes from non normalized 24-11-21 + ) + + linear_approx = LinearModel( + torch.from_numpy(ols_linear.alpha).float().to(device), + torch.from_numpy(ols_linear.beta).float().to(device), + ) + + random_linear_fvu = calculate_fvu(probe, linear_approx, random_loader, device) + print(f"Linear FVU for random data: {random_linear_fvu:.4f}") + ols_results[ckpt.name]['random_linear_fvu'] = random_linear_fvu + + linear_fvu = calculate_fvu(probe, linear_approx, cifar10_dataloader, device) + print(f"Linear FVU for CIFAR-10: {linear_fvu:.4f}") + ols_results[ckpt.name]['linear_fvu'] = linear_fvu + + torch.save(ols_results, out_path) + plot(ols_results) + + +if __name__ == "__main__": + main() diff --git a/experiments/prototyping.ipynb b/experiments/prototyping.ipynb new file mode 100644 index 0000000..9b6f3ed --- /dev/null +++ b/experiments/prototyping.ipynb @@ -0,0 +1,1020 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Files already downloaded and verified\n" + ] + } + ], + "source": [ + "from torchvision.datasets import CIFAR10\n", + "from torchvision.transforms.functional import to_tensor\n", + "import torch\n", + "\n", + "device = \"cuda:7\"\n", + "data = CIFAR10(root=\"/mnt/ssd-1/alexm/cifar10/\", download=True)\n", + "images, labels = zip(*data)\n", + "\n", + "X = torch.stack(list(map(to_tensor, images))).to(device)\n", + "Y = torch.tensor(labels).to(device)\n", + "\n", + "# Shuffle deterministically\n", + "rng = torch.Generator(device=X.device).manual_seed(42)\n", + "perm = torch.randperm(len(X), generator=rng, device=X.device)\n", + "X, Y = X[perm], Y[perm]\n", + "\n", + "X_vec = X.view(X.shape[0], -1)\n", + "k = int(Y.max()) + 1\n", + "\n", + "test_size = 1024\n", + "\n", + "X_vec_train = X_vec[:-test_size]\n", + "X_vec_test = X_vec[-test_size:]\n", + "\n", + "X_train, X_test = X[:-test_size], X[-test_size:]\n", + "Y_train, Y_test = Y[:-test_size], Y[-test_size:]" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_956395/225487411.py:5: FutureWarning: You are using `torch.load` with `weights_only=False` (the current default value), which uses the default pickle module implicitly. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling (See https://github.com/pytorch/pytorch/blob/main/SECURITY.md#untrusted-models for more details). In a future release, the default value for `weights_only` will be flipped to `True`. This limits the functions that could be executed during unpickling. Arbitrary objects will no longer be allowed to be loaded via this mode unless they are explicitly allowlisted by the user via `torch.serialization.add_safe_globals`. We recommend you start setting `weights_only=True` for any use case where you don't have full control of the loaded file. Please open an issue on GitHub for any issues related to this experimental feature.\n", + " state = torch.load('/mnt/ssd-1/lucia/ngrams-across-time/erasers_cache/cifar10_state.pth')\n" + ] + } + ], + "source": [ + "# state = torch.load(\"/home/nora/Data/erasers.pt\")\n", + "# oleace = state['oleace']\n", + "# qleace = state['qleace']\n", + "\n", + "state = torch.load('/mnt/ssd-1/lucia/ngrams-across-time/erasers_cache/cifar10_state.pth')\n", + "qleace = state['qleace'].to(device)\n", + "oleace = state['oleace'].to(device)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/opt/conda/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "import torch\n", + "from torch import Tensor, optim\n", + "\n", + "from mdl.probe import Probe\n", + "from mlp_mixer_pytorch import MLPMixer\n", + "\n", + "\n", + "class MixerProbe(Probe):\n", + " \"\"\"Multi-layer perceptron with ResNet architecture.\"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " num_features: int,\n", + " num_classes: int = 2,\n", + " device: str | torch.device = \"cpu\",\n", + " dtype: torch.dtype | None = None,\n", + " ):\n", + " super().__init__(num_features, num_classes, device, dtype)\n", + "\n", + " self.mixer = MLPMixer(\n", + " image_size=32,\n", + " channels=3,\n", + " patch_size=4,\n", + " dim=768,\n", + " depth=16,\n", + " num_classes=k,\n", + " ).to(dtype=dtype).to(device=device)\n", + "\n", + " def forward(self, x: Tensor) -> Tensor:\n", + " return self.mixer(x).squeeze(-1)\n", + "\n", + " def build_optimizer(self) -> optim.Optimizer:\n", + " return optim.SGD(\n", + " self.parameters(), lr=0.005, momentum=0.9, weight_decay=5e-4,\n", + " )\n", + "\n", + "\n", + "class VitProbe(Probe):\n", + " def __init__(\n", + " self,\n", + " num_classes: int = 2,\n", + " device: str | torch.device = \"cpu\",\n", + " dtype: torch.dtype | None = None,\n", + " ):\n", + " super().__init__(3, num_classes, device, dtype)\n", + "\n", + " from vit_pytorch import ViT\n", + "\n", + " self.vit = ViT(\n", + " channels=3,\n", + " depth=6,\n", + " dim=512,\n", + " dropout=0.1,\n", + " #emb_dropout=0.1,\n", + " heads=8,\n", + " image_size=32,\n", + " mlp_dim=1024,\n", + " num_classes=k,\n", + " patch_size=4,\n", + " ).to(dtype=dtype).to(device=device)\n", + "\n", + " def forward(self, x: Tensor) -> Tensor:\n", + " return self.vit(x).squeeze(-1)\n", + "\n", + " def build_optimizer(self) -> optim.Optimizer:\n", + " # Implicitly does learning rate warmup in a principled way\n", + " #return optim.SGD(self.parameters(), lr=0.005, momentum=0.9, weight_decay=0.1)\n", + " return optim.Adam(self.parameters())" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "from torchvision.transforms.v2 import RandAugment, AutoAugment\n", + "import torchvision as tv\n", + "\n", + "image_size = X.shape[-1]\n", + "padding = round(image_size * 0.125)\n", + "augmentor = tv.transforms.Compose(\n", + " [\n", + " tv.transforms.RandomCrop(image_size, padding=padding),\n", + " tv.transforms.RandomHorizontalFlip(),\n", + " #tv.transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010]),\n", + " # AutoAugment()\n", + " ]\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "torch.Size([50000, 3, 32, 32])\n", + "torch.Size([50000, 3072])\n" + ] + } + ], + "source": [ + "print(X.shape)\n", + "print(X_vec.shape)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "torch.Size([146928])" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# X_train.bfloat16().repeat(3, 1, 1, 1).flatten(1).shape\n", + "Y_train.repeat(3).shape\n", + "# Y_train.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "from mdl import Sweep, ResMlpProbe\n", + "\n", + "num_epochs = 1\n", + "num_seeds = 3\n", + "\n", + "def reshape(x):\n", + " \"reshape tensor to CxHxW\"\n", + " return x.view(-1, X.shape[1], X.shape[2], X.shape[3])\n", + "\n", + "flattened_image_augmentor = tv.transforms.Compose(\n", + " [\n", + " tv.transforms.Lambda(reshape),\n", + " tv.transforms.RandomCrop(image_size, padding=padding),\n", + " tv.transforms.RandomHorizontalFlip(),\n", + " tv.transforms.Lambda(lambda x: x.flatten(1)),\n", + " ]\n", + ")\n", + "sweep = Sweep(\n", + " X_vec.shape[1], k, device=X.device, dtype=torch.bfloat16,\n", + " num_chunks=10,\n", + " probe_cls=ResMlpProbe,\n", + " probe_kwargs=dict(num_layers=3),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 9/9 [00:54<00:00, 6.10s/scales, loss=2.1891]\n", + "100%|██████████| 9/9 [01:05<00:00, 7.33s/scales, loss=2.1870]\n", + "100%|██████████| 9/9 [00:57<00:00, 6.40s/scales, loss=2.1336]\n" + ] + } + ], + "source": [ + "results = [\n", + " sweep.run(\n", + " X_train.bfloat16().repeat(num_epochs, 1, 1, 1).flatten(1), Y_train.repeat(num_epochs), seed=i, \n", + " augment=flattened_image_augmentor, reduce_lr_on_plateau=False\n", + " )\n", + " for i in range(num_seeds)\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 9/9 [00:46<00:00, 5.16s/scales, loss=3.5582]\n", + "100%|██████████| 9/9 [00:46<00:00, 5.13s/scales, loss=3.4984]\n", + "100%|██████████| 9/9 [00:46<00:00, 5.14s/scales, loss=3.5297]\n" + ] + } + ], + "source": [ + "results_ = [\n", + " sweep.run(\n", + " X_train.bfloat16().repeat(num_epochs, 1, 1, 1).flatten(1), Y_train.repeat(num_epochs), seed=i,\n", + " transform=lambda x, y: qleace(x, y),\n", + " augment=flattened_image_augmentor,\n", + " )\n", + " for i in range(num_seeds)\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 0%| | 0/9 [00:00" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "for i in range(num_seeds):\n", + " # plt.plot(results[i].sample_sizes[:-1], results[i].ce_curve, alpha=0.15, c=\"black\")\n", + " plt.plot(results_[i].sample_sizes[:-1], results_[i].ce_curve, alpha=0.15, c=\"black\")\n", + " # plt.plot(results_[i].sample_sizes[:-1], results_linear_[i].ce_curve, alpha=0.15, c=\"black\")\n", + "\n", + "# plt.plot(results[0].sample_sizes[:-1], curve, label=\"Original\", marker=\"o\")\n", + "plt.plot(results_[0].sample_sizes[:-1], curve_, label=\"Q-LEACE\", marker=\"o\")\n", + "# plt.plot(results_[0].sample_sizes[:-1], curve_linear_, label=\"LEACE\", marker=\"o\")\n", + "\n", + "plt.hlines(np.log2(10), 0, 2 ** 16, label=\"Chance\", linestyle=\"--\", color=\"black\")\n", + "plt.legend()\n", + "plt.xscale(\"log\", base=2)\n", + "plt.xlabel(\"Training samples\")\n", + "plt.ylabel(\"Cross-entropy (bits)\")\n", + "plt.title(\"CIFAR-10 (3-Layer MLP)\")\n", + "plt.yscale(\"log\", base=2)\n", + "\n", + "# add numbers to y axis\n", + "# locs, _ = plt.yticks()\n", + "# locs = np.log2(locs)\n", + "# locs = np.round(locs).astype(int)\n", + "# plt.yticks(2 ** locs, [f\"2^{loc}\" for loc in locs])\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/opt/conda/lib/python3.10/site-packages/torchvision/models/_utils.py:208: UserWarning: The parameter 'pretrained' is deprecated since 0.13 and may be removed in the future, please use 'weights' instead.\n", + " warnings.warn(\n", + "/opt/conda/lib/python3.10/site-packages/torchvision/models/_utils.py:223: UserWarning: Arguments other than a weight enum or `None` for 'weights' are deprecated since 0.13 and may be removed in the future. The current behavior is equivalent to passing `weights=ResNet18_Weights.IMAGENET1K_V1`. You can also use `weights=ResNet18_Weights.DEFAULT` to get the most up-to-date weights.\n", + " warnings.warn(msg)\n" + ] + } + ], + "source": [ + "resnet = tv.models.resnet18(pretrained=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "BasicBlock(\n", + " (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)\n", + " (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (relu): ReLU(inplace=True)\n", + " (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)\n", + " (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + ")" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "resnet.layer1[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "from torchvision.models.resnet import BasicBlock" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "from torch import nn\n", + "\n", + "class MlpBlock(nn.Module):\n", + " def __init__(\n", + " self, in_features: int, out_features: int, device = None, dtype = None\n", + " ):\n", + " super().__init__()\n", + "\n", + " self.linear1 = nn.Linear(\n", + " in_features, out_features, bias=False, device=device, dtype=dtype\n", + " )\n", + " self.linear2 = nn.Linear(\n", + " out_features, out_features, bias=False, device=device, dtype=dtype\n", + " )\n", + " self.bn1 = nn.BatchNorm1d(\n", + " in_features, device=device, dtype=dtype\n", + " )\n", + " self.bn2 = nn.BatchNorm1d(\n", + " out_features, device=device, dtype=dtype\n", + " )\n", + " self.downsample = nn.Linear(\n", + " in_features, out_features, bias=False, device=device, dtype=dtype\n", + " ) if in_features != out_features else None\n", + "\n", + " def forward(self, x):\n", + " identity = x\n", + " \n", + " out = self.linear1(x)\n", + " out = self.bn1(out)\n", + " out = nn.functional.relu(out)\n", + "\n", + " out = self.linear2(out)\n", + " out = self.bn2(out)\n", + "\n", + " if self.downsample is not None:\n", + " identity = self.downsample(identity)\n", + "\n", + " out += identity\n", + " out = nn.functional.relu(out)\n", + "\n", + " return out" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "def to_bytes(x):\n", + " return x.mul(255).byte()\n", + "\n", + "def to_float(x):\n", + " return x.float().div(255)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Epoch: 0%| | 0/200 [00:00 dict | None: + """Parse run name parts into parameters.""" + + parts: list[str] = run.name.split(' ') + + try: + eraser, _, width_str, depth_str, seed_str, net = parts[:6] + + remaining_params = parts[6:] # unfortunately the order of these varies + + param_dict: dict[str, Any] = { + 'act': DISPLAY_NAMES['relu'] + } + for param in remaining_params: + if param.startswith('b1='): + param_dict['b1'] = float(param.split('=')[1]) + elif param.startswith('lr='): + param_dict['lr'] = float(param.split('=')[1]) + elif param.startswith('act='): + param_dict['act'] = DISPLAY_NAMES[param.split('=')[1]] + + param_dict.update({ + 'net_id': net, + 'seed': int(seed_str.split('=')[1]), + 'width': int(width_str.split('=')[1]), + 'depth': int(depth_str.split('=')[1]), + 'eraser': DISPLAY_NAMES[eraser], + 'net': DISPLAY_NAMES[net], + # 'date': run.created_at + }) + return param_dict + except: + return None + + +def parse_dataset(run: Run) -> str: + """Parse dataset from run name.""" + try: + with run.file('wandb-metadata.json').download(replace=True) as f: + metadata = json.load(f) + args = metadata['args'] + except: + print(list(run.files())) + return '' + if not args: + return '' + + str_args = ' '.join(args) + if '24-11-21' not in run.name and '24-11-19' not in run.name: + print(str_args) + + if 'fake-leace-cifar10' in str_args: + return 'fake-leace-cifar10' + elif 'fake-leace-cifarnet' in str_args: + return 'fake-leace-cifarnet' + elif 'fake-leace-svhn' in str_args: + return 'fake-leace-svhn' + elif 'fake-cifar10' in str_args: + return 'fake-cifar10' + elif 'fake-cifarnet' in str_args: + return 'fake-cifarnet' + elif 'cifarnet' in str_args: + return 'cifarnet' + elif 'svhn' in str_args: + return 'svhn' + return 'cifar10' # Some runs have no dataset tagged + + +def scrape_data(filename: Path): + api = wandb.Api(timeout=1000) + runs = api.runs("eleutherai/mdl") + + latest_runs = {} + for run in runs: + if '24-11-21' not in run.name and '24-11-19' not in run.name and 'results' not in run.name: + # if dataset_str == 'cifarnet' or 'resmlp' in run.name: + # if not 'result' in run.name and not 'cifarnet' in run.name: + # continue + # else: + continue + + dataset = parse_dataset(run) + # if dataset != dataset_str: + # continue + + params = parse_run_params(run) + if not params: + continue + + params['dataset'] = dataset + + params['date'] = run.created_at + + param_key = tuple(sorted(params.items())) + + if param_key not in latest_runs or run.created_at > latest_runs[param_key].created_at: + latest_runs[param_key] = run + + data = [] + for param_key, run in latest_runs.items(): + try: + params = dict(param_key) + + history = list(run.scan_history()) + if not history: + print(f"No loss data found for run {run.name}") + continue + + log2_max = int(history[-1]['_step']).bit_length() + steps = [2 ** i for i in range(log2_max)] + + run_data = [] + for row in history: + if row['_step'] in steps: + entry = { + **params, + 'loss': row['val/loss'], + 'step': row['_step'], + 'run': run.name + } + run_data.append(entry) + + data.extend(run_data) + + except Exception as e: + print(f"Error processing run {run.name}: {e}") + continue + + pd.DataFrame(data).to_csv(filename, index=False) + print(f"Saved loss curve to {filename}") + + diff --git a/experiments/sentiment_dataset.ipynb b/experiments/sentiment_dataset.ipynb new file mode 100644 index 0000000..f2132cb --- /dev/null +++ b/experiments/sentiment_dataset.ipynb @@ -0,0 +1,438 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Special tokens have been added in the vocabulary, make sure the associated word embeddings are fine-tuned or trained.\n", + "/mnt/ssd-1/alexm/miniconda3/envs/ql/lib/python3.11/site-packages/transformers/convert_slow_tokenizer.py:473: UserWarning: The sentencepiece tokenizer that you are converting to a fast tokenizer uses the byte fallback option which is not implemented in the fast tokenizers. In practice this means that the fast version of the tokenizer can produce unknown tokens whereas the sentencepiece version would have converted these unknown tokens into a sequence of byte tokens matching the original piece of text.\n", + " warnings.warn(\n", + "Special tokens have been added in the vocabulary, make sure the associated word embeddings are fine-tuned or trained.\n" + ] + } + ], + "source": [ + "from transformers import AutoModel, AutoTokenizer, AutoConfig\n", + "from datasets import load_dataset\n", + "import torch\n", + "import random\n", + "import numpy as np\n", + "\n", + "seed = 4\n", + "random.seed(seed)\n", + "np.random.seed(seed)\n", + "torch.manual_seed(seed)\n", + "torch.cuda.manual_seed_all(seed)\n", + "\n", + "do_random = True\n", + "model_name = \"microsoft/deberta-v3-xsmall\"\n", + "ds_name = \"amazon_polarity\"\n", + "device = \"cuda:1\"\n", + "dtype = torch.float16\n", + "tokenizer = AutoTokenizer.from_pretrained(model_name)\n", + "if do_random:\n", + " config = AutoConfig.from_pretrained(model_name)\n", + " model = AutoModel.from_config(config).to(device).to(dtype)\n", + "else:\n", + " model = AutoModel.from_pretrained(model_name).to(device).to(dtype)\n", + "ds_dict = load_dataset(ds_name)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatasetDict({\n", + " train: Dataset({\n", + " features: ['label', 'title', 'content'],\n", + " num_rows: 3600000\n", + " })\n", + " test: Dataset({\n", + " features: ['label', 'title', 'content'],\n", + " num_rows: 10000\n", + " })\n", + "})" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from datasets import DatasetDict\n", + "# ds_dict = DatasetDict({\"train\": ds_dict[\"train\"].select(range(16)), \"test\": ds_dict[\"test\"].select(range(16))})\n", + "ds_dict = DatasetDict({\"train\": ds_dict[\"train\"].select(range(3_600_000)), \"test\": ds_dict[\"test\"].select(range(10_000))})\n", + "ds_dict" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Map: 0%| | 0/3600000 [00:00\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
erasurece_curve0sample_sizes0mdl0median_ce_curvesample_sizesmdl
0None[0.5572372298016042, 0.5184670482402801, 0.471...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...1.734498[0.5572372298016042, 0.5184670482402801, 0.471...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...1.734498
1LEACE[0.9629797119754583, 0.8902902826988269, 0.815...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...1.592949[0.9629797119754583, 0.8902902826988269, 0.815...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...1.592949
2Q-LEACE[1.0001316850755007, 1.0000007406898412, 1.000...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...4.151715[1.0001316850755007, 1.0000007406898412, 1.000...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...4.151715
3Linear[1.0000017788955973, 1.0001298788163426, 1.000...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...3.593908[1.0000017788955973, 1.0001298788163426, 1.000...[768, 1984, 3909, 6957, 11783, 19424, 31523, 5...3.593908
\n", + "" + ], + "text/plain": [ + " erasure ce_curve0 \\\n", + "0 None [0.5572372298016042, 0.5184670482402801, 0.471... \n", + "1 LEACE [0.9629797119754583, 0.8902902826988269, 0.815... \n", + "2 Q-LEACE [1.0001316850755007, 1.0000007406898412, 1.000... \n", + "3 Linear [1.0000017788955973, 1.0001298788163426, 1.000... \n", + "\n", + " sample_sizes0 mdl0 \\\n", + "0 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 1.734498 \n", + "1 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 1.592949 \n", + "2 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 4.151715 \n", + "3 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 3.593908 \n", + "\n", + " median_ce_curve \\\n", + "0 [0.5572372298016042, 0.5184670482402801, 0.471... \n", + "1 [0.9629797119754583, 0.8902902826988269, 0.815... \n", + "2 [1.0001316850755007, 1.0000007406898412, 1.000... \n", + "3 [1.0000017788955973, 1.0001298788163426, 1.000... \n", + "\n", + " sample_sizes mdl \n", + "0 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 1.734498 \n", + "1 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 1.592949 \n", + "2 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 4.151715 \n", + "3 [768, 1984, 3909, 6957, 11783, 19424, 31523, 5... 3.593908 " + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAuAAAAIRCAYAAAALEAi1AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAACROElEQVR4nOzdd3iUVfrG8e9MeqeGmhB6EwHpNkBQwIbYO6CLLsKii6Kiq64VXBuLou5awB+rC8oqWFFBKVJEmgXpNYAQanpImfn9cTIJgQCZZDLvlPtzXe/1TqY+QyA8c3LOfWxOp9OJiIiIiIh4hd3qAkREREREgokacBERERERL1IDLiIiIiLiRWrARURERES8SA24iIiIiIgXqQEXEREREfEiNeAiIiIiIl4UanUBwczhcLB3717i4uKw2WxWlyMiIiIiJ3A6nWRmZtKwYUPsds+MXasBt9DevXtJSkqyugwREREROYPU1FQaN27skedSA26huLg4wHxD4+PjLa5GRERERE6UkZFBUlJSSd/mCWrALeSadhIfH68GXERERMSHeXK6sBZhioiIiIh4kRpwEREREREvUgMuIiIiIuJFasBFRERERLxIDbiIiIiIiBepARcRERER8SI14CIiIiIiXqQGXERERETEi9SAi4iIiIh4kRpwEREREREvUgMuIiIiIuJFasBFRERERLxIDbiIiIiIiBepAa+izz//nNatW9OyZUvefvttq8sRERERER8XanUB/qywsJCxY8fy/fffk5CQQJcuXRgyZAi1a9e2ujQRERER8VEaAa+CFStW0L59exo1akRsbCyDBg3im2++sbqsUzpweB2/b/g/DhxeZ3UpUkX6XnqG/hzLF+x/LsH2/oPp/QbDe03LTmPl3pWkZadZXUq1CYTvY1A34IsWLeKKK66gYcOG2Gw2Zs+efdJ9pkyZQkpKCpGRkfTo0YMVK1aU3LZ3714aNWpU8nWjRo3Ys2ePN0p324/LHyHji3Oo8dMdZHxxDj8uf8TqkqSS9L30DP05li/Y/1yC7f0H0/sNhvc6Z8Mcek/rzZCZQ+g9rTdzNsyxuiSPC5Tvo83pdDqtLsIqX331FUuWLKFLly5cffXVfPLJJ1x11VUlt8+cOZPbb7+dN998kx49ejBp0iQ++ugjNm7cSGJiIrNmzWLBggW89tprALzwwgvYbDYeeOCB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hIwMk7pSZZmbzTm8NoTFeuAJxe0G/O9//ztvv/02d911F0uWLCEiIgKAkJAQHn744UoVsWfPHm699VZq165NVFQUHTp0YOXxW01V0aJFi7jiiito2LAhNpuN2bNnl3u/KVOmkJKSQmRkJD169GDFihUeq0FERESkRHRxmkjWVo88XWKimfNdq5bZiTM21lz3wgumOa8SV43RSVWuU4xKxRBee+21J103dOjQShVw5MgRzjvvPPr27ctXX31F3bp12bx58ykjDZcsWUL37t0JCwsrc/3vv/9O7dq1qVev3kmPyc7OpmPHjtxxxx1cffXV5T7vzJkzGTt2LG+++SY9evRg0qRJDBgwgI0bN5JY/LubTp06UVhYeNJjv/nmGxo2bOjuWxcREZFgFZMCBxZD9g6PPeXgwdCrl5l20rAhTJkC//sfvPqq2RjoyScrGWWYVVxjjCIIPaVSDfjChQt58cUXWb9+PQDt2rVj3LhxXHDBBW4/1/PPP09SUhJTp04tua5p0/JX2DocDkaNGkXLli2ZMWNGyZzzjRs3ctFFFzF27FgefPDBkx43aNAgBg0adNo6Xn75ZUaMGMHw4cMBePPNN/niiy949913S0b2165d6/b7ExERETlJbIo5Z+/06NMmJpbO+X76aahf3zTis2aZaMNXXoHISDef1FVjTIonSw1qbn8O+s9//kP//v2Jjo5mzJgxjBkzhqioKPr168cHH3zgdgGffvopXbt25brrriMxMZHOnTvz1ltvlV+s3c6XX37JmjVruP3223E4HGzdupWLLrqIq666qtzmuyLy8/NZtWpVyXx212v179+fZceHcHrIlClTaNeuHd26dfP4c4uIiIgfcC1mzNldbS9hs8Ho0fDUU2Yh54IFcPvtcOSIm0+Us8ucY0+/WFQqzu0G/Nlnn+Uf//gHM2fOLGnAZ86cycSJE3n66afdLmDbtm288cYbtGzZkq+//pqRI0cyZsyYUyaJNGzYkO+++44ffviBm2++mYsuuoj+/fuXpLFUxsGDBykqKjpp+kq9evXYt29fhZ+nf//+XHfddXz55Zc0btz4lM37qFGj+P333/npp58qXbOIiIj4sfhW5px/CAqyqvWlrrvOTEOJjjapKTfdZKapVIjTAbl7zWUloHiM2w34tm3buOKKK066/sorr2T79u1uF+BwODjnnHN47rnn6Ny5M3fddRcjRozgzTffPOVjkpOTmT59OjNnziQ0NJR33nkHm83m9mt72rx58zhw4AA5OTns3r2bXr16WV2SiIiI+KLwmhCWYC67UkaqUZ8+MG0a1K5t8sJvusnkip9Rzm5w5IMtVFNQPMjtBjwpKYn58+efdP28efNISnJ/dWyDBg1od3ywJdC2bVt27dp1ysfs37+fu+66iyuuuIKcnBz++te/uv26x6tTpw4hISHs37//pNepX79+lZ5bREREpFzRjc050zNJKGfSoQN88AEkJcGhQzB0KCxceIYHuWISoxpASKWWDko53G7A77//fsaMGcPIkSOZPn0606dP589//jP33XcfDzzwgNsFnHfeeWzcuLHMdZs2baJJkybl3v/gwYP069ePtm3b8vHHHzN//nxmzpxZqdd2CQ8Pp0uXLmU+WDgcDubPn69RbBEREakerijC7G1ee8nkZJgxA846C3Jy4C9/MQs0TymruLaoxl6pL1i4/VFm5MiR1K9fn5deeokPP/wQMCPWM2fOZPDgwW4X8Ne//pVzzz2X5557juuvv54VK1bw73//m3//+98n3dfhcDBo0CCaNGlSMv2kXbt2fPvtt1x00UU0atSo3NHwrKwstmwpDbrfvn07a9eupVatWiQnm7/8Y8eOZejQoXTt2pXu3bszadIksrOzS1JRRERERDzKNaXDFfPnJbVqwfTpcO+9sGgRPPYYpKXBPfeUc2dXTGJM+QOjUjk2p9PprOidCwsLee6557jjjjto3Nhzn4Q+//xzxo8fz+bNm2natCljx45lxIgR5d7322+/5YILLiDyhAydNWvWULdu3XLrWrBgAX379j3p+qFDhzJt2rSSr1977TVeeOEF9u3bR6dOnZg8eTI9evSo2ps7jYyMDBISEkhPTyc+Pr7aXkdERER80O7PYe04iG0BfT7z+ssXFcETT5iscIBrry0nK3zpbXB4JbQbD81u93qNvqA6+jW3GnCA2NhYfvvtN1JSUjxSQDBTAy4iIhLE0jfC4qvAHgWDVpncQC9zOk1O+JQp5us+fU7ICp/XB/L2Q/e3IPF8r9fnC6qjX3N7Dni/fv1YeMYZ+yIiIiJyWrFNATs4ckuj/rzstFnhhccgL83cMa6FJfUFKrfngA8aNIiHH36YX3/9lS5duhATE1Pm9iuvvNJjxYmIiIgErJBwky6S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h+4/RyXmvPbv35/69eszYcKEU97nf//7H+3btyciIoKUlBReeumlMrenpKTw3HPPcccddxAXF0dycjL//ve/y9wnNTWV66+/nho1alCrVi0GDx7Mjh073K5XpEJiY+GWW2DWLJgzx1yuVat04eY115iFm9OmaeGmiHiHa263a7Hl8bJdCSin2aZeLKE54P4kL88sBquoggLYuNGM1IWEmBHwoUOhVStwZ5OYxYvNr97dEBISwnPPPcfNN9/MmDFjaNy47KfvVatWcf311/P3v/+dG264gaVLl3LPPfdQu3Zthg0bVnK/l156iaeffppHHnmEWbNmMXLkSHr37k3r1q0pKChgwIAB9OrVi8WLFxMaGsozzzzDwIED+eWXX7QdvFSvVq3MaPjDD8O338L//mcWcW7YYI5XXoE+fcxc8fPOMyPpIiKe5tpePme3GTA7/meNKx9cEYQ+RyPggSw/v7T5ttvNuajIa/NVhwwZQqdOnXjiiSdOuu3ll1+mX79+PPbYY7Rq1Yphw4YxevRoXnjhhTL3u/TSS7nnnnto0aIFDz30EHXq1OH7778HYObMmTgcDt5++206dOhA27ZtmTp1Krt27WLBggXeeIsiEBoKgwaZFJX582HUKLOoMz/fLNocMQL694dJk2DPHqurFZFAE9sMsEFRNuSdMNXUNSquBBSfoxFwfxIZaUajKyotDQYMML8Kr1ULDh+G+HiYO7f8RWene91Kev7557nooot44IEHyly/fv16Bp+w7fd5553HpEmTKCoqIiQkBICzzz675HabzUb9+vVJK57L/vPPP7Nlyxbi4uLKPE9eXh5bt26tdM0ileZauDlqlJkfPmsWfPdd6cLNt96Crl3NVJWBA0G/pRGRqgqNgshEyNtvogij6pnrnU7ILY4gdI2Si89QA+5PbDb3poI0aQLPPw/jx5s5qjVqwIQJ5novufDCCxkwYADjx48vM7WkosJOmCpjs9lwOBwAZGVl0aVLF95///2THle3bt1K1SviEa6Fmz17QnY2zJ4Nn3wC69bBihXmeO458wH5hhugXTurKxYRfxadZBrwrK2QeJ65Lm8/FOUCNojRCLivUQMe6E6VdexFEydOpFOnTrRu3brkurZt27JkyZIy91uyZAmtWrUqGf0+k3POOYeZM2eSmJhIfHy8R2sW8ZiYGLNY85ZbYNMms1jz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/PHHT/v43NzcMqPjAHFxcTRv3hwwEYP79u0rc3tERAQ1a9bknXfe4bLLLqNjx45lbm/Xrh1//etfSz4ATJ06lf79+3PBBRfw6KOP0qZNG7Kysvjss8/45ptvysQQHj169KTXi4uLIyYmxt0/GhEROR2bzey82aoV3H67uW7nTtOQ//STach374bt283xySfmPo0alUYf9uoFTZoo+lB8ms3p7oRY8ZiMjAwSEhJIT08nPj6+zG15eXls376dpk2bEhnp/hxsq+3YsYO///3vzJ07l7S0NJxOJ1dffTXTp08/KbP7eH//+9958sknT7q+X79+zJs3jz59+pRpjl0GDBjAe++9R+PGjfnggw9Oyv0GuOeee1i+fDmrV68GYNOmTTz77LPMmzePgwcP0qBBA84991zGjRtXMjXlVEktEyZM4OGHHz7pen//vomI+Lz9+8tGH27fbkbOj1enTtmGvFUr05CnpZnM8uTkwI2aDIb36GWn69cqSw24hQK5AT/RE088wcsvv8y3335Lz549rS6n2gTa901ExOcdOVI6Qr5mjZnCUlRU9j4JCSah5ZdfzELQqCgzwt6zp5kGY7OZ8/HHqa4r7/rTPbYi9z/T67quP5M5c+DhhyEryyxcnTjRbLYkVVIdDbimoIhXPPnkk6SkpLB8+XK6d+9ebv62iIiI22rWhEGDzAEmVeX4LPING8yGQatXm5Fyu93klb/0khkl9qcM8uMbdNeHAddRVGRy1wsLITzc5K/ffbd5f337wml++yze50d/68TfDR8+3OoSREQk0MXEmIazb1/z9bFj8P77MHZs2Wa1oMA04AkJpjF3OMz9HQ5zuK5zOsteLu+2U11/qvse/3rucD3mxBF+MPnphYWmOXe9h0OHYPRo03ynpEC7dmZX0s6dzY6kx4UViHepARcREZHAFRFhdup84QUz8l2rlhkdTkiA//7X2nnSrkb8dEdF7uNwwIEDcMstpdNPDh0yHzbq1DHXbdtmjs8/N68dGWniHdu3N035OeeYDyRavOoVasBFREQksCUmmvnQ48ebnPGEBJgwwfpFijabGYX2xEh0ixZmN9Hx481uo40amfc4eDDs2WOm4Pz8s8ld37gRcnNL89ZnzDDPUaOGGRlv397EPJ5zjjLXq4kWYVoomBZhBgt930REfFhaGqSmQlKS9c13danIe3TNF3ft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Ofq+iYiIBJaMjAwSEhLK7dcqyydGwEeNGsVll11G//79T3s/u93Ol19+yZo1a7j99ttxOBxs3bqViy66iKuuuuqMzfep5Ofns2rVqjKvb7fb6d+//0mbwnjCqFGj+P333/npp588/twiIiIi4tssX4Q5Y8YMVq9eXeFmtGHDhnz33XdccMEF3HzzzSxbtoz+/fvzxhtvVLqGgwcPUlRURL0TtjqtV68eGzZsqPDz9O/fn59//pns7GwaN27MRx99RK9evSpdl4iIiIgEHksb8NTUVO69916+/fZbt35dn5yczPTp0+nduzfNmjXjnXfeKYmvs9K8efOsLkFEREREfJylU1BWrVpFWloa55xzDqGhoYSGhrJw4UImT55MaGgoRUVF5T5u//793HXXXVxxxRXk5OTw17/+tUp11KlTh5CQkJMWce7fv5/69etX6blFRERERI5naQPer18/fv311zJRdV27duWWW25h7dq1JYssj3fw4EH69etH27Zt+fjjj5k/fz4zZ87kgQceqHQd4eHhdOnShfnz55dc53A4mD9/vuVTSHxgjay4Qd8vERERORNLp6DExcVx1llnlbkuJiaG2rVrn3Q9mKZ40KBBNGnShJkzZxIaGkq7du349ttvueiii2jUqFG5o+FZWVls2bKl5Ovt27ezdu1aatWqRXJyMgBjx45l6NChdO3ale7duzNp0iSys7MZPny4h991xbh2h8zJySEqyo3Nd8RSrt03y/vwKCIiIgI+sAjTHXa7neeee44LLriA8PDwkus7duzIvHnzqFu3brmPW7lyJX379i35euzYsQAMHTqUadOmAXDDDTdw4MABHn/8cfbt20enTp2YO3fuSQszvSUkJIQaNWqU5JBHR0f7xDx3OTWHw8GBAweIjo4mNNSv/mmJiIiIF/lEDniwOlOupNPpZN++fRw9etT7xUml2O12mjZtWuYDooiIiPiv6sgB1zCdD7PZbDRo0IDExMRyd/8U3xMeHo7d7hPx+iIiIuKj1ID7gZCQEM0pFhEREQkQGqoTEREREfEiNeAiIiIiIl6kBlxERERExIs0B9xCrgCajIwMiysRERERkfK4+jRPBgeqAbdQZmYmAElJSRZXIiIiIiKnk5mZSUJCgkeeSzngFnI4HOzdu5e4uLgqbbLTrVs3fvrpJ68/3t3HZWRkkJSURGpqqsdyNINdVb/3vsCX3oM3a6mu1/Lk8+pnS/DypX+XleVL70E/Wzz3PFV5vDuPPf7nSlxcHJmZmTRs2NBjUcMaAbeQ3W6ncePGVX6ekJCQKv2nU9nHV/Zx8fHx+k/SQ6r6vfcFvvQevFlLdb2WJ59XP1uCly/9u6wsX3oP+tniueepyuMr81jXzxVPjXy7aBFmABg1apQlj6/q60rVBcL3wJfegzdrqa7X8uTz6mdL8AqE74EvvQf9bPHc81Tl8b70d0JTUMRrqmMrVxER/WwREU+r7p8rGgEXr4mIiOCJJ54gIiLC6lJEJIDoZ4uIeFp1/1zRCLiIiIiIiBdpBFxERERExIvUgIuIiIiIeJEacBERERERL1IDLiIiIiLiRWrARURERES8SA24+ISjR4/StWtXOnXqxFlnncVbb71ldUkiEiBycnJo0qQJDzzwgNWliEiASElJ4eyzz6ZTp0707dvX7cdrK3rxCXFxcSxatIjo6Giys7M566yzuPrqq6ldu7bVpYmIn3v22Wfp2bOn1WWISIBZunQpsbGxlXqsRsDFJ4SEhBAdHQ3AsWPHcDqdKKJeRKpq8+bNbNiwgUGDBlldiohICTXg4hGLFi3iiiuuoGHDhthsNmbPnn3SfaZMmUJKSgqRkZH06NGDFStWlLn96NGjdOzYkcaNGzNu3Djq1KnjpepFxBd54ufKAw88wIQJE7xUsYj4A0/8bLHZbPTu3Ztu3brx/vvvu12DGnDxiOzsbDp27MiUKVPKvX3mzJmMHTuWJ554gtWrV9OxY0cGDBhAWlpayX1q1KjBzz//zPbt2/nggw/Yv3+/t8oXER9U1Z8rc+bMoVWrVrRq1cqbZYuIj/NEz/LDDz+watUqPv30U5577jl++eUX94pwingY4Pzkk0/KXNe9e3fnqFGjSr4uKipyNmzY0DlhwoRyn2PkyJHOjz76qDrLFBE/UpmfKw8//LCzcePGziZNmjhr167tjI+Pdz755JPeLFtEfJwnepYHHnjAOXXqVLdeVyPgUu3y8/NZtWoV/fv3L7nObrfTv39/li1bBsD+/fvJzMwEID09nUWLFtG6dWtL6hUR31eRnysTJkwgNTWVHTt28OKLLzJixAgef/xxq0oWET9QkZ8t2dnZJT1LVlYW3333He3bt3frdZSCItXu4MGDFBUVUa9evTLX16tXjw0bNgCwc+dO7rrrrpLFl3/5y1/o0KGDFeWKiB+oyM8VERF3VeRny/79+xkyZAgARUVFjBgxgm7durn1OmrAxSd0796dtWvXWl2GiASoYcOGWV2CiASIZs2a8fPPP1fpOTQFRapdnTp1CAkJOWlR5f79+6lfv75FVYmIP9PPFRGpDt762aIGXKpdeHg4Xbp0Yf78+SXXORwO5s+fT69evSysTET8lX6uiEh18NbPFk1BEY/Iyspiy5YtJV9v376dtWvXUqtWLZKTkxk7dixDhw6la9eudO/enUmTJpGdnc3w4cMtrFpEfJl+rohIdfCJny3uhbWIlO/77793AicdQ4cOLbnPq6++6kxOTnaGh4c7u3fv7ly+fLl1BYuIz9PPFRGpDr7ws8XmdGq/bxERERERb9EccBERERERL1IDLiIiIiLiRWrARURERES8SA24iIiIiIgXqQEXEREREfEiNeAiIiIiIl6kBlxERERExIvUgIuIiIiIeJEacBERERERL1IDLiIiHmOz2Zg9e3a1vsbf//53OnXqVK2vISJSndSAi4j4kQMHDjBy5EiSk5OJiIigfv36DBgwgCVLllhdmsd88skn9OzZk4SEBOLi4mjfvj333Xdfye0PPPAA8+fPt65AEZEqCrW6ABERqbhrrrmG/Px83nvvPZo1a8b+/fuZP38+hw4dsro0j5g/fz433HADzz77LFdeeSU2m43ff/+db7/9tuQ+sbGxxMbGWliliEjVaARcRMRPHD16lMWLF/P888/Tt29fmjRpQvfu3Rk/fjxXXnllyf1efvllOnToQExMDElJSdxzzz1kZWWV3D5t2jRq1KjB559/TuvWrYmOjubaa68lJyeH9957j5SUFGrWrMmYMWMoKioqeVxKSgpPP/00N910EzExMTRq1IgpU6actubU1FSuv/56atSoQa1atRg8eDA7duw45f0/++wzzjvvPMaNG0fr1q1p1aoVV111VZnXOXEKis1mO+lISUkpuf23335j0KBBxMbGUq9ePW677TYOHjxYgT9xEZHqoQZcRMRPuEZ+Z8+ezbFjx055P7vdzuTJk1m3bh3vvfce3333HQ8++GCZ++Tk5DB58mRmzJjB3LlzWbBgAUOGDOHLL7/kyy+/ZPr06fzrX/9i1qxZZR73wgsv0LFjR9asWcPDDz/MvffeW2Z0+ngFBQUMGDCAuLg4Fi9ezJIlS4iNjWXgwIHk5+eX+5j69euzbt06fvvttwr/ufzxxx8lx5YtW2jRogUXXnghYD60XHTRRXTu3JmVK1cyd+5c9u/fz/XXX1/h5xcR8TiniIj4jVmzZjlr1qzpjIyMdJ577rnO8ePHO3/++efTPuajjz5y1q5du+TrqVOnOgHnli1bSq67++67ndHR0c7MzMyS6wYMGOC8++67S75u0qSJc+DAgWWe+4YbbnAOGjSo5GvA+cknnzidTqdz+vTpztatWzsdDkfJ7ceOHXNGRUU5v/7663JrzcrKcl566aVOwNmkSRPnDTfc4HznnXeceXl5Jfd54oknnB07djzpsQ6HwzlkyBBnly5dnDk5OU6n0+l8+umnnZdcckmZ+6WmpjoB58aNG8utQUSkumkEXETEj1xzzTXs3buXTz/9lIEDB7JgwQLOOeccpk2bVnKfefPm0a9fPxo1akRcXBy33XYbhw4dIicnp+Q+0dHRNG/evOTrevXqkZKSUmZudb169UhLSyvz+r169Trp6/Xr15db688//8yWLVuIi4srGb2vVasWeXl5bN26tdzHxMTE8MUXX7Blyxb+9re/ERsby/3330/37t3L1F+eRx55hGXL/r+9+wmFrY/jOP5xu4ppkoQaSlPEjEhI14pSQ4omVqRkGAspppSNxII0ilmgWMrMgsnCko0JE4WUlRRjYTESK0om41ncUjL3qXvznOeq96vOYs75/s7vO6vzmfNv9rWxsaHU1NS3Hra3t9/mN5vNstlskvTLHgDgv8ZDmADwxaSkpMjhcMjhcGh0dFRut1tjY2Pq6urS1dWVmpqa1NfXp8nJSWVkZGhvb089PT16fn6WyWSSJCUnJ7/bZ1JSUsJ18Xj8j/t8eHhQZWWlAoHAh21ZWVn/OjY/P1/5+flyu90aGRlRYWGhVldX5XK5Etb7/X75fD6FQiHl5ua+66G5uVler/fDGIvF8pvfCAA+BwEcAL644uLit3dvHx8fKx6Pa2ZmRt++/bzIuba29mlzHRwcfPhst9sT1lZUVGh1dVXZ2dlKS0v74zmtVqtMJpMeHx8Tbt/f35fb7dbS0pKqq6s/9LC+vi6r1arv3znkAfg7cAsKAHwRd3d3qqurk9/v1+npqSKRiILBoKanp+V0OiVJBQUFisVimpub0+XlpVZWVrS4uPhpPYTDYU1PT+v8/FwLCwsKBoMaHBxMWNvR0aHMzEw5nU7t7u4qEokoFAppYGBA19fXCceMj49reHhYoVBIkUhEJycn6u7uViwWk8Ph+FAfjUbV0tKitrY2NTQ0KBqNKhqN6vb2VpLU39+v+/t7tbe36/DwUBcXF9rc3JTL5Xr3hhcAMBIBHAC+CLPZrB8/fsjn86mmpkYlJSUaHR1Vb2+v5ufnJUllZWWanZ2V1+tVSUmJAoGApqamPq2HoaEhHR0dqby8XBMTE5qdnVVDQ0PCWpPJpJ2dHeXl5am1tVV2u109PT16enr65Rnx2tpaXV5eqrOzUzabTY2NjYpGo9ra2lJRUdGH+rOzM93c3Gh5eVkWi+VtqaqqkiTl5OQoHA7r5eVF9fX1Ki0tlcfjUXp6+tsVAgAwWtLr6+vr/90EAODvZ7Va5fF43v0rJQDg9/HzHwAAADAQARwAAAAwELegAAAAAAbiDDgAAABgIAI4AAAAYCACOAAAAGAgAjgAAABgIAI4AAAAYCACOAAAAGAgAjgAAABgIAI4AAAAYKB/ABN7yawLu2XKAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 6))\n", + "for erasure, color in zip(erasures, [\"red\", \"blue\", \"green\", \"orange\"]):\n", + " sub_df = df[df.erasure == erasure]\n", + " x = sub_df.sample_sizes.iloc[0][:-1]\n", + " y = sub_df.median_ce_curve.iloc[0]\n", + " ax.plot(x, y, label=erasure, alpha=0.8, marker=\".\", color=color)\n", + " for seed in seeds:\n", + " x = sub_df.sample_sizes.iloc[0][:-1]\n", + " y = sub_df[f\"ce_curve{seed}\"].iloc[0]\n", + " ax.plot(x, y, alpha=0.2, color=color)\n", + "ax.set_xlabel(\"Sample Size\")\n", + "ax.set_ylabel(\"Cross Entropy\")\n", + "# ax.set_ylim(0, 1.1)\n", + "ax.loglog()\n", + "ax.legend()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ql", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/experiments/sentiment_erasure.py b/experiments/sentiment_erasure.py new file mode 100644 index 0000000..93e6f7b --- /dev/null +++ b/experiments/sentiment_erasure.py @@ -0,0 +1,85 @@ +import argparse +import os +import pickle + +from concept_erasure import LeaceFitter, OracleFitter, QuadraticFitter +from datasets import load_dataset + +from mdl import MlpProbe, Sweep + + +def main(args): + embeddings_seed = args.embeddings_seed # None means not random + ds_name = "atmallen/amazon_polarity_embeddings" + ( + f"_random{embeddings_seed}" if embeddings_seed is not None else "" + ) + print(ds_name) + ds_dict = load_dataset(ds_name) + device = args.device + n_train = 2**17 + seed = args.seed + print("Shuffling... ", end="") + ds_dict = ds_dict.with_format("torch", columns=["embedding", "label"]).shuffle( + seed=seed + ) + print("done.") + num_classes = ds_dict["train"].features["label"].num_classes + X_train = ds_dict["train"]["embedding"][:n_train] + X_train = X_train / X_train.norm(dim=-1, keepdim=True) + Y_train = ds_dict["train"]["label"][:n_train] + + # for erasure_method in ["Q-LEACE", "None", "Linear", "LEACE"]: + for erasure_method in ["None", "LEACE"]: + print(f"Erasure: {erasure_method}") + + if erasure_method != "None": + fitter_class = { + "Linear": OracleFitter, + "Q-LEACE": QuadraticFitter, + "LEACE": LeaceFitter, + }[erasure_method] + fitter = fitter_class.fit(X_train, Y_train) + eraser = fitter.eraser + X_train_ = ( + eraser(X_train, Y_train) + if erasure_method != "LEACE" + else eraser(X_train) + ) + else: + X_train_ = X_train.clone() + + sweep = Sweep( + num_features=X_train_.shape[1], + num_classes=num_classes, + num_chunks=10, + probe_cls=MlpProbe, + val_frac=0.2, + device=device, + probe_kwargs=dict( + num_layers=2, + ), + ) + result = sweep.run( + X_train_.to(device), + Y_train.to(device).to(float), + seed=seed, + max_epochs=1000, + ) + print(result) + out_path = os.path.join( + args.out_dir, f"{erasure_method}_seed{seed}_on_{ds_name.split('/')[-1]}.pkl" + ) + # pickle result + with open(out_path, "wb") as f: + pickle.dump(result, f) + print(f"Saved result to {out_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--embeddings-seed", type=int, default=None) + parser.add_argument("--device", type=str, default="cuda") + parser.add_argument("--out-dir", type=str, default="../data") + args = parser.parse_args() + main(args) diff --git a/experiments/sweep_eraser.py b/experiments/sweep_eraser.py new file mode 100644 index 0000000..6842a1b --- /dev/null +++ b/experiments/sweep_eraser.py @@ -0,0 +1,354 @@ +import subprocess +from argparse import ArgumentParser +from pathlib import Path +from glob import glob + + +def run_training( + width: int | None, + depth: int | None, + arch: str | None, + eraser: str, + lr: float, + b1: float, + mup_width: int | None, + mup_depth: int | None, + mup_arch: str | None, + args, +): + cmd = [ + f"CUDA_VISIBLE_DEVICES={args.device}", + "python", + "-m", + "experiments.cli", + "--name", + f"{args.out}", + "--eraser", + f"{eraser}", + "--out", + f"{args.out}", + "--net", + args.net, + "--lr", + f"{lr}", + "--b1", + f"{b1}", + "--act", + f"{args.act}", + "--dataset", + f"{args.dataset}", + ] + + if arch is not None: + cmd.extend(["--arch", f"{arch}", "--mup_arch", f"{mup_arch}"]) + else: + cmd.extend( + [ + "--width", + f"{width}", + "--depth", + f"{depth}", + "--mup_width", + f"{mup_width}", + "--mup_depth", + f"{mup_depth}", + ] + ) + + if args.normalize: + cmd.append("--normalize") + if args.nocache: + cmd.append("--nocache") + if args.overwrite: + cmd.append("--overwrite") + + print("\nLaunching training...") + print("Command:", " ".join(cmd)) + + try: + process = subprocess.run(" ".join(cmd), shell=True, check=True, text=True) + print("process exit code", process.returncode) + except subprocess.CalledProcessError as e: + print(f"Error during training: {e}") + # print("Continuing to train...") + exit(0) + + +def parse_args(): + parser = ArgumentParser() + parser.add_argument("--device", type=int, default=0) + parser.add_argument("--net", type=str, default="convnext") + parser.add_argument("--out", type=str, default="24-11-21") + parser.add_argument("--start", type=int, default=0) + parser.add_argument("--width", action="store_true") + parser.add_argument("--depth", action="store_true") + parser.add_argument("--normalize", action="store_true") + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--nocache", action="store_true") + parser.add_argument( + "--dataset", + type=str, + choices=( + "mnist", + "cifarnet", + "cifar10", + "fake-cifar10", + "fake-cifarnet", + "svhn", + "fake-svhn", + "fake-leace-cifar10", + "fake-leace-cifarnet", + "fake-leace-svhn", + ), + default="cifar10", + ) + parser.add_argument( + "--erasers", nargs="+", default=["control", "qleace", "leace", "alf_qleace"] + ) # "random" + parser.add_argument( + "--act", type=str, choices=("relu", "gelu", "swiglu"), default="relu" + ) + return parser.parse_args() + + +# Most to least powerful +sweep_params = { + "lenet": { + "lr": { + "control": 5e-4, # Guessing + "leace": 5e-4, # Guessing + "qleace": 5e-4, # Guessing + "alf_qleace": 5e-4, # Guessing + }, + "b1": { + "control": 0.95, # Guessing + "leace": 0.95, # Guessing + "qleace": 0.95, # Guessing + "alf_qleace": 0.95, # Guessing + }, + "mup_width": 128, + "mup_depth": 2, + # These will be converted from the MLP of this size to a parameter-matched LeNet + "widths": [64, 128, 256, 512, 1024, 2048], + "depths": [1, 2, 3, 4, 6, 8], + }, + 'mlp': { + "fake-leace-cifar10": { # guessing + 'lr': { + 'control': 1e-4, + 'leace': 1e-4, + 'qleace': 1e-4, + 'alf_qleace': 1e-4, + 'random': 1e-4, + }, + 'b1': { + 'control': 0.95, + 'leace': 0.95, + 'qleace': 0.95, + 'alf_qleace': 0.95, + 'random': 0.95, + }, + }, + 'svhn': { + 'lr': { + 'control': 1e-4, + 'leace': 1e-4, + 'qleace': 1e-4, + 'alf_qleace': 1e-4, # guessing + 'random': 1e-4, + }, + "b1": { + "control": 0.95, # was 0.99 for cifar10 + "leace": 0.95, + "qleace": 0.95, + "alf_qleace": 0.95, # guessing + "random": 0.95, + }, + }, + "fake-leace-cifar10": { + "lr": { + "control": 1e-4, # Verified + }, + "b1": { + "control": 0.95, + }, + }, + "lr": { + "control": 5e-4, + "leace": 5e-4, + "qleace": 5e-4, + "alf_qleace": 5e-4, # guessing + "random": 5e-4, + }, + "b1": { + "control": 0.95, # was 0.99 for cifar10 + "leace": 0.95, + "qleace": 0.95, + "alf_qleace": 0.95, # guessing + "random": 0.95, + }, + "mup_width": 128, + "mup_depth": 2, + "widths": [64, 128, 256, 512, 1024, 2048], + "depths": [1, 2, 3, 4, 6, 8], # # Loses coherence at 16, 1 breaks probe + }, + "convnext": { + "lr": { + "control": 5e-5, + "leace": 1e-4, + "qleace": 1e-3, + "alf_qleace": 1e-3, + }, + "b1": { + "control": 0.9, + "leace": 0.9, + "qleace": 0.9, + "alf_qleace": 0.9, + }, + # "archs": ["atto", "femto", "pico", "nano", "tiny"], + # "mup_arch": "atto", + + # Width specifies the first stage; at each additional stage the width is doubled + 'mup_width': 40, + 'mup_depth': 2, + 'widths': [40, 48, 64], # 80, 96 + 'depths': [2, 3, 4] + + }, + "swin": { + "lr": { + "control": 1e-3, + "leace": 1e-3, + "qleace": 1e-3, + "alf_qleace": 1e-3, + }, + "b1": { + "control": 0.9, + "leace": 0.9, + "qleace": 0.9, + "alf_qleace": 0.9, + }, + # "archs": ["atto", "femto", "pico", "nano", "tiny"], + # "mup_arch": "atto", + # Original values + 'mup_width': 32, + 'mup_depth': 2, + 'widths': [32, 64, 128], # 256, 512 + 'depths': [2, 4, 8] + }, + "resmlp": { + "mup_width": 128, + "mup_depth": 2, + "widths": [64, 128, 256, 512, 1024, 2048], + "depths": [1, 2, 3, 4, 6, 8], + "lr": { + "control": 5e-4, + "leace": 5e-4, + "qleace": 5e-4, + "alf_qleace": 5e-4, # guessing + }, + "b1": { + "control": 0.99, + "leace": 0.95, + "qleace": 0.95, + "alf_qleace": 0.95, # guessing + }, + }, +} + + +def artifact_exists( + width: int | None, depth: int | None, arch: str | None, eraser: str, args +): + assert not (width is None and arch is None) + + if arch is not None: + patterns = [ + f"{args.net}_{args.act}_arch={arch}_{eraser}_*_d={args.dataset}.pth", + f"{args.net}_{args.act}_arch={arch}_{eraser}_*_{args.dataset}.pth", + ] + else: + patterns = [ + f"{args.net}_{args.act}_h={width}_d={depth}_{eraser}_*_d={args.dataset}.pth", + f"{args.net}_{args.act}_h={width}_d={depth}_{eraser}_*_{args.dataset}.pth", + ] + + for pattern in patterns: + full_pattern = str(Path(f"{args.out}") / pattern) + if glob(full_pattern): + return True + + return False + + +def main(): + args = parse_args() + + params = sweep_params[args.net] + + for eraser in args.erasers: + if args.dataset in sweep_params[args.net]: + lr = sweep_params[args.net][args.dataset]["lr"][eraser] + b1 = sweep_params[args.net][args.dataset]["b1"][eraser] + print("Using dataset specific lr and b1") + else: + lr = sweep_params[args.net]["lr"][eraser] + b1 = sweep_params[args.net]["b1"][eraser] + + # if args.net in ["swin", "convnext"]: + # for arch in params["archs"][args.start :]: + # if args.overwrite or not artifact_exists( + # None, None, arch, eraser, args + # ): + # run_training( + # None, + # None, + # arch, + # eraser, + # lr, + # b1, + # None, + # None, + # params["mup_arch"], + # args, + # ) + # else: + if args.width: + for width in params["widths"][args.start :]: + if args.overwrite or not artifact_exists( + width, params["mup_depth"], None, eraser, args + ): + run_training( + width, + params["mup_depth"], + None, + eraser, + lr, + b1, + params["mup_width"], + params["mup_depth"], + None, + args, + ) + + if args.depth: + for depth in params["depths"][args.start :]: + if args.overwrite or not artifact_exists( + params["mup_width"], depth, None, eraser, args + ): + run_training( + params["mup_width"], + depth, + None, + eraser, + lr, + b1, + params["mup_width"], + params["mup_depth"], + None, + args, + ) + + +if __name__ == "__main__": + main() diff --git a/mdl/__init__.py b/mdl/__init__.py index d88a589..c8d3b31 100644 --- a/mdl/__init__.py +++ b/mdl/__init__.py @@ -1,14 +1,16 @@ from .math import partition_logspace -from .mlp_probe import LinearProbe, MlpProbe +from .mlp_probe import LinearProbe, ResMlpProbe from .quadratic_probe import QuadraticProbe from .sweep import Sweep +from .resnet_probe import ResNetProbe from .vision_probe import VisionProbe __all__ = [ "partition_logspace", "LinearProbe", - "MlpProbe", + "ResMlpProbe", "QuadraticProbe", "Sweep", - "VisionProbe", + "ResNetProbe", + "VisionProbe" ] diff --git a/mdl/lenet_probe.py b/mdl/lenet_probe.py new file mode 100644 index 0000000..3eee1df --- /dev/null +++ b/mdl/lenet_probe.py @@ -0,0 +1,128 @@ +import math +import json +import torch +from dataclasses import dataclass +from torch import nn +from torch import optim +from mup import MuReadout, MuAdam, load_base_shapes, set_base_shapes +from schedulefree import AdamWScheduleFree + +from .probe import Probe + + +@dataclass +class LeNetConfig: + image_size: int + num_channels: int + conv_hidden_sizes: list[int] + fc_hidden_sizes: list[int] + kernel_sizes: list[int] + num_labels: int + kernel_size: int = 5 + +class LeNet5(nn.Module): + def _conv_output_size(self, size, kernel_size): + return (size - kernel_size) + 1 + + def __init__(self, cfg: LeNetConfig): + super(LeNet5, self).__init__() + + self.cfg = cfg + fc_hidden_size_1, fc_hidden_size_2 = cfg.fc_hidden_sizes + conv_hidden_size_1, conv_hidden_size_2 = cfg.conv_hidden_sizes + kernel_size = cfg.kernel_size + # Get feature map size after two convolutions and two max pools + self.feature_map_size = self._conv_output_size(cfg.image_size, kernel_size) // 2 + self.feature_map_size = self._conv_output_size(self.feature_map_size, kernel_size) // 2 + + # Define parameters + self.conv1 = nn.Conv2d(cfg.num_channels, conv_hidden_size_1, kernel_size=kernel_size) + self.conv2 = nn.Conv2d(conv_hidden_size_1, out_channels=conv_hidden_size_2, kernel_size=kernel_size) + + self.fc1 = nn.Linear(conv_hidden_size_2 * self.feature_map_size * self.feature_map_size, + fc_hidden_size_1) + self.fc2 = nn.Linear(fc_hidden_size_1, fc_hidden_size_2) + self.fc3 = nn.Linear(fc_hidden_size_2, cfg.num_labels) + + def forward(self, x): + x = torch.relu(self.conv1(x)) + x = torch.max_pool2d(x, 2) + x = torch.relu(self.conv2(x)) + x = torch.max_pool2d(x, 2) + + x = x.view(-1, + self.cfg.conv_hidden_sizes[1] * self.feature_map_size * self.feature_map_size) + + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + x = self.fc3(x) + return x + + +class LeNetProbe(Probe): + """Only defines a single size of probe""" + def __init__( + self, + num_features: int, + num_classes: int = 2, + num_layers: int = 2, + hidden_size: int = 2, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, + conv_hidden_sizes: list[int] | None = None, + fc_hidden_sizes: list[int] | None = None, + **kwargs + ): + if not conv_hidden_sizes and not fc_hidden_sizes: + print("Single probe size being used, input size ignored. Provide conv_hidden_sizes and fc_hidden_sizes.") + + super().__init__(num_features, num_classes, device, dtype) + + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + + image_size = int(math.sqrt(num_features // 3)) + + conv_hidden_sizes = conv_hidden_sizes or [hidden_size] * num_layers + fc_hidden_sizes = fc_hidden_sizes or [hidden_size] * num_layers + + cfg = LeNetConfig( + image_size=image_size, + num_channels=3, + conv_hidden_sizes=conv_hidden_sizes, + kernel_sizes=[5, 5], + fc_hidden_sizes=fc_hidden_sizes, + num_labels=10 + ) + self.net = LeNet5(cfg).to(device=device, dtype=dtype) + + # Configure MuP + self.net.fc3 = MuReadout( + self.net.fc3.in_features, + self.net.fc3.out_features, + device=device, + dtype=dtype, + readout_zero_init=True + ) + + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + + def build_optimizer(self): + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam(self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas) + return opt_cls(self.parameters(), lr=self.learning_rate, betas=self.betas) + + def forward(self, x): + return self.net(x) + diff --git a/mdl/mlp_probe.py b/mdl/mlp_probe.py index bd8e1c1..0a6e25a 100644 --- a/mdl/mlp_probe.py +++ b/mdl/mlp_probe.py @@ -1,54 +1,211 @@ -from functools import partial from itertools import pairwise +from functools import partial import torch from torch import Tensor, nn, optim +from schedulefree import AdamWScheduleFree, ScheduleFreeWrapper +from mup import MuReadout, MuAdam, MuSGD, load_base_shapes, set_base_shapes +from muon import Muon from .probe import Probe +class SwiGLU(torch.nn.Module): + r"""Applies the SwiGLU function element-wise. + SwiGLU is defined as: + .. math:: + \text{SwiGLU}(x, y) = x * \sigma(y) + where :math:`\sigma` is the sigmoid function, and :math:`x` and :math:`y` are + split from the input tensor along the given dimension. + Args: + dim (int): the dimension on which to split the input. Default: -1 + Shape: + - Input: :math:`(\ast_1, N, \ast_2)` where `*` means any number of additional + dimensions + - Output: :math:`(\ast_1, M, \ast_2)` where :math:`M=N/2` + Examples:: + >>> m = nn.SwiGLU() + >>> input = torch.randn(4, 2) + >>> output = m(input) + """ + + __constants__ = ["dim"] + dim: int + + def __init__(self, dim: int = -1) -> None: + super().__init__() + self.dim = dim + + def forward(self, input: Tensor) -> Tensor: + x, y = torch.chunk(input, 2, dim=self.dim) + + return x * torch.sigmoid(y) + + def extra_repr(self) -> str: + return f"dim={self.dim}" + + class MlpProbe(Probe): - """Multi-layer perceptron probe with GELU activation.""" + def __init__( + self, + num_features: int, + num_classes: int = 2, + hidden_size: int | None = None, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + num_layers: int = 2, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + activation: str = "relu", + schedule_free: bool = False, + base_shapes_path: str | None = None, + muon=False, + **kwargs + ): + super().__init__(num_features, num_classes, device, dtype) + + self.learning_rate = learning_rate + self.schedule_free = schedule_free + self.betas = betas + self.mup = base_shapes_path is not None + self.muon = muon + + act = { + "relu": nn.ReLU(), + "gelu": nn.GELU(), + "swiglu": SwiGLU(), + }[activation] + + assert hidden_size is not None + k, h = num_classes, hidden_size + + in_features, out_features = h, h + + # Reduce h by a factor of 2/3 to keep the number of parameters constant + if activation == "swiglu": + swiglu_h = h * 2 // 3 + in_features = swiglu_h # Swiglu output is one vector of len (h * 2 // 3) + out_features = ( + swiglu_h * 2 + ) # Swiglu input is equivalent to two concatenated vectors of len (h * 2 // 3) + + self.net = nn.Sequential( + nn.Linear(num_features, out_features, device=device, dtype=dtype), + act, + *[ + nn.Sequential( + nn.Linear(in_features, out_features, device=device, dtype=dtype), + act, + ) + for _ in range(num_layers - 1) + ], + MuReadout( + in_features, k, device=device, dtype=dtype, readout_zero_init=True + ), + ) + + # Configure MuP + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + def build_optimizer(self): + if self.muon: + print("Not using MuP - not implemented for muon") + muon_params = [p for p in self.net.parameters() if p.ndim >= 2] + adamw_params = [p for p in self.net.parameters() if p.ndim < 2] + + optimizer = Muon( + muon_params, + lr=0.02, + momentum=0.95, + adamw_params=adamw_params, + adamw_lr=self.learning_rate, + adamw_betas=self.betas, + adamw_wd=0.01 # type: ignore + ) + return ScheduleFreeWrapper(optimizer) + # opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + # opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam( + self.parameters(), AdamWScheduleFree, lr=self.learning_rate, betas=self.betas, warmup_steps=1000 + ) + return AdamWScheduleFree(self.parameters(), lr=self.learning_rate, betas=self.betas, warmup_steps=1000) + + def forward(self, x: Tensor) -> Tensor: + return self.net(x) + + +class ResMlpProbe(Probe): + """Multi-layer perceptron with ResNet architecture.""" def __init__( self, num_features: int, num_classes: int = 2, + hidden_size: int | None = None, device: str | torch.device | None = None, dtype: torch.dtype | None = None, *, num_layers: int = 2, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, + **kwargs ): super().__init__(num_features, num_classes, device, dtype) self.num_layers = num_layers + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + + if hidden_size is None: + hidden_size = ( + 4 * num_features if num_layers == 2 else round(num_features * 4 / 3) + ) - # Same expansion ratio as Vaswani et al. (2017) - hidden_dim = ( - 4 * num_features if num_layers == 2 else round(num_features * 4 / 3) - ) output_dim = num_classes if num_classes > 2 else 1 - sizes = [num_features] + [hidden_dim] * (num_layers - 1) + [output_dim] + sizes = [num_features] + [hidden_size] * (num_layers - 1) - self.net = nn.Sequential() - for in_dim, out_dim in pairwise(sizes): - self.net.append( - nn.Linear(in_dim, out_dim, device=device, dtype=dtype), - ) - self.net.append(nn.GELU()) + self.trunk = nn.Sequential( + *[ + MlpBlock(in_dim, out_dim, device=device, dtype=dtype) + for in_dim, out_dim in pairwise(sizes) + ] + ) - self.net.pop(-1) # Remove last activation + self.fc = MuReadout( + sizes[-1], output_dim, device=device, dtype=dtype, readout_zero_init=True + ) + + # Configure MuP + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) def forward(self, x: Tensor) -> Tensor: - return self.net(x).squeeze(-1) + features = self.trunk(x) + + return self.fc(features).squeeze(-1) def build_optimizer(self) -> optim.Optimizer: if self.num_layers > 1: - return optim.AdamW(self.parameters()) + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam( + self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas + ) + return opt_cls(self.parameters(), lr=self.learning_rate, betas=self.betas) else: # Use Nesterov SGD for linear probes. The problem is convex and there's # really no need to use an adaptive learning rate. We can set the fixed # LR considerably higher and this seems to help with convergence. - return optim.SGD( + opt_cls = MuSGD if self.mup else optim.SGD + return opt_cls( self.parameters(), # Learning rate of 0.1 with momentum 0.9 is "really" an LR of unity in # PyTorch's parametrization; see https://youtu.be/k8fTYJPd3_I @@ -61,5 +218,40 @@ def build_optimizer(self) -> optim.Optimizer: ) -# Convenience alias LinearProbe = partial(MlpProbe, num_layers=1) + + +class MlpBlock(nn.Module): + def __init__(self, in_features: int, out_features: int, device=None, dtype=None): + super().__init__() + + self.linear1 = nn.Linear( + in_features, out_features, bias=False, device=device, dtype=dtype + ) + self.linear2 = nn.Linear( + out_features, out_features, bias=False, device=device, dtype=dtype + ) + self.bn1 = nn.BatchNorm1d(out_features, device=device, dtype=dtype) + self.bn2 = nn.BatchNorm1d(out_features, device=device, dtype=dtype) + self.downsample = ( + nn.Linear(in_features, out_features, bias=False, device=device, dtype=dtype) + if in_features != out_features + else None + ) + + def forward(self, x): + identity = x + out = self.linear1(x) + out = self.bn1(out) + out = nn.functional.relu(out) + + out = self.linear2(out) + out = self.bn2(out) + + if self.downsample is not None: + identity = self.downsample(identity) + + out += identity + out = nn.functional.relu(out) + + return out diff --git a/mdl/probe.py b/mdl/probe.py index 18fc24c..55829b4 100644 --- a/mdl/probe.py +++ b/mdl/probe.py @@ -1,11 +1,15 @@ -import math +from pathlib import Path from abc import ABC, abstractmethod +from copy import deepcopy from typing import Callable +import math +import numpy as np import torch from torch import Tensor, nn, optim +from schedulefree import AdamWScheduleFree, ScheduleFreeWrapper from torch.nn.functional import ( - binary_cross_entropy_with_logits as bce_with_logits, + binary_cross_entropy_with_logits as bce_loss, ) from torch.nn.functional import ( cross_entropy, @@ -27,9 +31,6 @@ def __init__( self.num_classes = num_classes self.num_features = num_features - def augment_data(self, x: Tensor) -> Tensor: - return x - @abstractmethod def build_optimizer(self) -> optim.Optimizer: ... @@ -40,13 +41,20 @@ def fit( x: Tensor, y: Tensor, *, - batch_size: int = 32, + augment: Callable[[Tensor], Tensor] = lambda x: x, + batch_size: int = 128, early_stop_epochs: int = 4, max_epochs: int = 50, - preprocessor: Callable[[Tensor, Tensor], Tensor] = lambda x, _: x, + # TODO remove + reduce_lr_on_plateau: bool = True, + return_validation_losses: bool = False, seed: int = 42, + transform: Callable[[Tensor, Tensor], Tensor] = lambda x, _: x, verbose: bool = False, - return_validation_losses: bool = False, + x_val: Tensor | None = None, + y_val: Tensor | None = None, + logger = None, + ckpt_every: int | None = None ): """Fits the model to the input data using Adam with L2 regularization. @@ -71,65 +79,213 @@ def fit( x = x.to(self.dtype) # Shuffle the data so we don't learn in a weirdly structured order - rng = torch.Generator(device=x.device).manual_seed(seed) - perm = torch.randperm(len(x), generator=rng, device=x.device) - x, y = x[perm], y[perm] + if x_val is None or y_val is None: + rng = torch.Generator(device=x.device).manual_seed(seed) + perm = torch.randperm(len(x), generator=rng, device=x.device) + x, y = x[perm], y[perm] - val_size = min(4096, len(x) // 5) - assert val_size > 0, "Dataset is too small to split into train and val" - val_losses = [] + val_size = min(2048, len(x) // 5) + assert val_size > 0, "Dataset is too small to split into train and val" - x_train, y_train = x[val_size:], y[val_size:] - x_val, y_val = x[:val_size], y[:val_size] + x_train, y_train = x[val_size:], y[val_size:] + x_val, y_val = x[:val_size], y[:val_size] + else: + x_train, y_train = x, y + val_size = len(x_val) + val_losses = [] y = y.to( torch.get_default_dtype() if self.num_classes == 2 else torch.long, ) opt = self.build_optimizer() - schedule = optim.lr_scheduler.ReduceLROnPlateau( - opt, factor=0.5, patience=0, threshold=0.01 - ) + pbar = trange(max_epochs, desc="Epoch", disable=not verbose) + best_loss = torch.inf + best_opt_state = opt.state_dict() + best_state = self.state_dict() + num_plateaus = 0 + self.eval() - x_val = self.augment_data(x_val) - x_val = preprocessor(x_val, y_val) - - for _ in pbar: - # Check early stop criterion - if ( - opt.param_groups[0]["lr"] - < opt.defaults["lr"] * 0.5**early_stop_epochs - ): - break - - # Train on batches + # Check for possible bug when using AdamWScheduleFree with MuAdam (MuAdam should) + # return AdamWScheduleFree such that this call works but...) + if isinstance(opt, AdamWScheduleFree) or isinstance(opt, ScheduleFreeWrapper): + opt.eval() + x_val = transform(x_val, y_val) + + # Record initial weights for weight change norm logging + initial_weights = deepcopy(self.state_dict()) if logger is not None else None + + for i, ep in enumerate(pbar): + val_loss = self.evaluate(x_val, y_val, batch_size) + val_acc = self.accuracy(x_val, y_val, batch_size) + + if val_loss < best_loss: + best_loss = val_loss + best_opt_state = deepcopy(opt.state_dict()) + best_state = deepcopy(self.state_dict()) + num_plateaus = 0 + else: + num_plateaus += 1 + + # Early stopping + if num_plateaus >= early_stop_epochs: + break + + # Backtrack + opt.load_state_dict(best_opt_state) + self.load_state_dict(best_state) + + val_losses.append(best_loss) + pbar.set_postfix(loss=best_loss) + + ### TRAIN LOOP ### self.train() + if isinstance(opt, AdamWScheduleFree) or isinstance(opt, ScheduleFreeWrapper): + opt.train() + train_losses = [] - for x_batch, y_batch in zip( - x_train.split(batch_size), y_train.split(batch_size) - ): + for x_batch, y_batch in zip(x_train.split(batch_size), y_train.split(batch_size)): opt.zero_grad() - x_batch = self.augment_data(x_batch) - x_batch = preprocessor(x_batch, y_batch) - self.loss(x_batch, y_batch).backward() + x_batch = augment(transform(x_batch, y_batch)) + loss = self.loss(x_batch, y_batch) + train_losses.append(loss.item()) + loss.backward() opt.step() - # Validate - with torch.no_grad(): - self.eval() + if logger is not None: + # Calculate norm of parameters' mean differences from initialization + # ( + # w_frobenius_norm, w_spectral_norm, b_l1, b_frobenius, + # ) = self.dist_from_init(initial_weights) + # w_frobenius_norms, w_spectral_norms, b_l1_norms, b_frobenius_norms + + log_data = { + "train/loss": sum(train_losses) / len(train_losses), + "val/loss": val_loss, + "val/accuracy": val_acc, + "step": (i * len(x_train) // batch_size) + len(x_train) // batch_size, + "learning_rate": opt.param_groups[0]["lr"], + "epoch": i, + # "mean_norms/weight_frobenius": w_frobenius_norm, + # "mean_norms/weight_spectral": w_spectral_norm, + # "mean_norms/bias_l1": b_l1, + # "mean_norms/bias_frobenius": b_frobenius, + + } + + logger.log(log_data) + + if ckpt_every is not None and i % ckpt_every == 0: + if not (Path("probe-ckpts").exists()): + Path("probe-ckpts").mkdir(parents=True) + torch.save(self.state_dict(), Path(f"probe-ckpts/{logger.name}-{i}.pth")) - loss = self.loss(x_val, y_val) - schedule.step(loss) + # Load parameters with lowest validation loss + self.load_state_dict(best_state) - val_losses.append(loss.item()) - pbar.set_postfix(loss=loss.item()) if return_validation_losses: return val_losses - def loss(self, x: Tensor, y: Tensor) -> Tensor: + @torch.no_grad() + def accuracy(self, x: Tensor, y: Tensor, batch_size: int) -> float: + """Compute average accuracy on `(x, y)` in batches of size `batch_size`.""" + total_correct = sum( + self(x_batch).argmax(dim=-1).eq(y_batch).sum().item() + for x_batch, y_batch in zip(x.split(batch_size), y.split(batch_size)) + ) + return total_correct / len(x) + + @torch.no_grad() + def evaluate(self, x: Tensor, y: Tensor, batch_size: int) -> float: + """Compute average loss on `(x, y)` in batches of size `batch_size`.""" + # breakpoint() + total_loss = sum(self.loss(x_batch, y_batch).item() * len(x_batch) for x_batch, y_batch in zip(x.split(batch_size), y.split(batch_size))) + return total_loss / len(x) + + def loss_fn(self, logits: Tensor, target: Tensor, smoothing: float = 0) -> Tensor: + """Computes the loss of the predictions on the given data.""" + # print(logits.shape, target.shape) + # print("Target min/max:", target.min().item(), target.max().item()) + + + return ( + cross_entropy(logits, target.long()) + if logits.ndim == 2 + else bce_loss(logits, target) + ) / math.log(2) + + def loss(self, x: Tensor, y: Tensor, smoothing: float = 0.1) -> Tensor: """Computes the loss of the probe on the given data.""" - loss_fn = bce_with_logits if self.num_classes == 2 else cross_entropy - return loss_fn(self(x.to(self.dtype)).squeeze(-1), y) / math.log(2) + return self.loss_fn(self(x.to(self.dtype)).squeeze(-1), y, smoothing) + + + def dist_from_init(self, initial_weights) -> tuple[float, float, float, float]: + """Calculate Frobenius and spectral norms of weight changes for logging.""" + current_weights = self.state_dict() + + num_weights = len([weight for weight in current_weights if 'weight' in weight]) + num_biases = len([bias for bias in current_weights if 'bias' in bias]) + assert num_weights > 0, "No weights found in model" + + w_frobenius_norm = 0. + w_spectral_norm = 0. + b_l1 = 0. + b_frobenius = 0. + + for name, current_param in current_weights.items(): + if 'weight' in name and len(current_param.shape) >= 2: + weight_diff = current_param - initial_weights[name] + if len(weight_diff.shape) > 2: + weight_diff = weight_diff.reshape(weight_diff.shape[0], -1) + + w_frobenius_norm += torch.norm(weight_diff, p='fro').item() / num_weights + + # Calculate only the largest singular value + U, S, Vh = torch.svd_lowrank(weight_diff, q=1) + w_spectral_norm += S[0].item() / num_weights + + if 'bias' in name: + bias_diff = current_param - initial_weights[name] + b_l1 += torch.norm(bias_diff, p=1).item() / num_biases + b_frobenius += torch.norm(bias_diff, p=2).item() / num_biases + + return w_frobenius_norm, w_spectral_norm, b_l1, b_frobenius + + # def dist_from_init(self, initial_weights) -> tuple: + # """Calculate Frobenius and spectral norms of weight changes for logging.""" + # current_weights = self.state_dict() + + # w_frobenius_norms = {} + # w_spectral_norms = {} + # b_l1_norms = {} + # b_frobenius_norms = {} + + # for name, current_param in current_weights.items(): + # if 'weight' in name and len(current_param.shape) >= 2: + # weight_diff = current_param - initial_weights[name] + # if len(weight_diff.shape) > 2: + # weight_diff = weight_diff.reshape(weight_diff.shape[0], -1) + + # w_frobenius_norms[name] = torch.norm(weight_diff, p='fro').item() + + # # Calculate largest singular value + # U, S, Vh = torch.svd_lowrank(weight_diff, q=1) + # w_spectral_norms[name] = S[0].item() + + # if 'bias' in name: + # bias_diff = current_param - initial_weights[name] + + # b_l1_norms[name] = torch.norm(bias_diff, p=1).item() + # b_frobenius_norms[name] = torch.norm(bias_diff, p=2).item() + + + # w_spectral_norm = np.mean([v for v in w_spectral_norms.values()]) + # w_frobenius_norm = np.mean([v for v in w_frobenius_norms.values()]) + # b_l1 = np.mean([v for v in b_l1_norms.values()]) + # b_frobenius = np.mean([v for v in b_frobenius_norms.values()]) + + # return (w_frobenius_norm, w_spectral_norm, b_l1, b_frobenius, + # w_frobenius_norms, w_spectral_norms, b_l1_norms, b_frobenius_norms) \ No newline at end of file diff --git a/mdl/quadratic_probe.py b/mdl/quadratic_probe.py index 18bf204..3a5ab0d 100644 --- a/mdl/quadratic_probe.py +++ b/mdl/quadratic_probe.py @@ -1,21 +1,32 @@ import torch from torch import Tensor, nn, optim +from mup import MuAdam, MuReadout, load_base_shapes, set_base_shapes +from schedulefree import AdamWScheduleFree from .probe import Probe class QuadraticProbe(Probe): """Probe of the form `y_i = x.T @ A @ x + b.T @ x + c`.""" - def __init__( self, num_features: int, num_classes: int = 2, device: str | torch.device | None = None, dtype: torch.dtype | None = None, + *, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, ): super().__init__(num_features, num_classes, device, dtype) + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + self.norm = nn.BatchNorm1d(num_classes, device=device, dtype=dtype) self.bilinear = nn.Bilinear( num_features, @@ -25,15 +36,24 @@ def __init__( device=device, dtype=dtype, ) - self.linear = nn.Linear( + self.linear = MuReadout( num_features, num_classes, device=device, dtype=dtype, + readout_zero_init=True ) + # Configure MuP + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + def build_optimizer(self) -> optim.Optimizer: - return optim.AdamW(self.parameters()) + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam(self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas) + return opt_cls(self.parameters(), lr=self.learning_rate, betas=self.betas) def forward(self, x: Tensor) -> Tensor: return self.norm(self.bilinear(x, x)) + self.linear(x) diff --git a/mdl/resnet_probe.py b/mdl/resnet_probe.py new file mode 100644 index 0000000..291cb73 --- /dev/null +++ b/mdl/resnet_probe.py @@ -0,0 +1,214 @@ +import torch +import torch.nn as nn +from torch import Tensor, optim +from typing import Optional + +from mup import MuSGD, MuReadout, load_base_shapes, set_base_shapes +from mdl.probe import Probe + +class BasicBlock(nn.Module): + expansion = 1 + + def __init__( + self, + in_channels: int, + out_channels: int, + stride: int = 1, + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None + ): + super().__init__() + self.conv1 = nn.Conv2d( + in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False, + device=device, dtype=dtype + ) + self.bn1 = nn.BatchNorm2d(out_channels, device=device, dtype=dtype) + self.relu = nn.ReLU(inplace=True) + self.conv2 = nn.Conv2d( + out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False, + device=device, dtype=dtype + ) + self.bn2 = nn.BatchNorm2d(out_channels, device=device, dtype=dtype) + + self.shortcut = nn.Sequential() + if stride != 1 or in_channels != out_channels: + self.shortcut = nn.Sequential( + nn.Conv2d( + in_channels, out_channels, kernel_size=1, stride=stride, bias=False, + device=device, dtype=dtype + ), + nn.BatchNorm2d(out_channels, device=device, dtype=dtype) + ) + + def forward(self, x: Tensor) -> Tensor: + out = self.relu(self.bn1(self.conv1(x))) + out = self.bn2(self.conv2(out)) + out += self.shortcut(x) + return self.relu(out) + +class ResNet(nn.Module): + def __init__( + self, + num_layers: int, + num_classes: int = 2, + num_blocks: int = 2, + hidden_size: int = 128, + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None + ): + super().__init__() + + self.conv1 = nn.Conv2d( + 3, hidden_size, kernel_size=3, stride=1, padding='same', bias=False, + device=device, dtype=dtype + ) + self.bn1 = nn.BatchNorm2d(hidden_size, device=device, dtype=dtype) + self.relu = nn.ReLU(inplace=True) + + self.stages = nn.ModuleList() + in_channels = hidden_size + current_channels = hidden_size + for i in range(num_layers): + # Double channels and reduce spatial dimensions every stage after the first + if i > 0: + current_channels *= 2 + stride = 2 + else: + stride = 1 + + stage = self._make_stage( + in_channels, current_channels, num_blocks, stride, + device=device, dtype=dtype + ) + self.stages.append(stage) + in_channels = current_channels + + # Global average pooling and final fully connected layer + self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) + self.fc = nn.Linear( + current_channels, num_classes, + device=device, dtype=dtype + ) + + # Initialize weights + self._initialize_weights() + + def _make_stage( + self, + in_channels: int, + out_channels: int, + num_blocks: int, + stride: int, + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None + ) -> nn.Sequential: + layers = [] + # First block may have stride > 1 to reduce spatial dimensions + layers.append(BasicBlock(in_channels, out_channels, stride, device=device, dtype=dtype)) + + # Remaining blocks maintain spatial dimensions + for _ in range(1, num_blocks): + layers.append(BasicBlock(out_channels, out_channels, 1, device=device, dtype=dtype)) + + return nn.Sequential(*layers) + + def _initialize_weights(self): + for m in self.modules(): + if isinstance(m, nn.Conv2d): + nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') + elif isinstance(m, nn.BatchNorm2d): + nn.init.constant_(m.weight, 1) + nn.init.constant_(m.bias, 0) + + def forward(self, x: Tensor) -> Tensor: + x = self.relu(self.bn1(self.conv1(x))) + + for stage in self.stages: + x = stage(x) + + x = self.avgpool(x) + x = torch.flatten(x, 1) + x = self.fc(x) + return x + +class ResNetProbe(Probe): + """Probe based on a custom ResNet implementation with configurable layers.""" + + def __init__( + self, + num_classes: int = 2, + num_layers: int = 4, + hidden_size: int = 128, + learning_rate: float = 0.005, + momentum: float = 0.9, + weight_decay: float = 5e-4, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + num_features: int = 3, + base_shapes_path: str | None = None, + **kwargs + ): + super().__init__(num_features, num_classes, device, dtype) + + self.net = ResNet( + num_layers=num_layers, + num_classes=num_classes, + num_blocks=2, + hidden_size=hidden_size, + device=device, + dtype=dtype + ) + + self.learning_rate = learning_rate + self.momentum = momentum + self.weight_decay = weight_decay + self.device = device + self.mup = base_shapes_path is not None + + """ + import torch + import torchvision + transform = transforms.ToTensor() + trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) + trainloader = torch.utils.data.DataLoader(trainset, batch_size=len(trainset), shuffle=False) + data = next(iter(trainloader))[0] + mean = data.mean(dim=[0,2,3]) # mean for each channel + std = data.std(dim=[0,2,3]) # std for each channel + print(f'mean: {mean}, std: {std}') + # Results: + # mean: tensor([0.4914, 0.4822, 0.4465]) + # std: tensor([0.2470, 0.2435, 0.2616]) + """ + self.register_buffer('mean', torch.tensor([0.4914, 0.4822, 0.4465]).view(1, 3, 1, 1)) + self.register_buffer('std', torch.tensor([0.2470, 0.2435, 0.2616]).view(1, 3, 1, 1)) + self.mean_device = self.mean.to(self.device) + self.std_device = self.std.to(self.device) + + # Configure MuP + self.net.fc = MuReadout( + self.net.fc.in_features, + self.net.fc.out_features, + device=device, + dtype=dtype, + readout_zero_init=True + ) + + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + + + def build_optimizer(self) -> optim.Optimizer: + opt_cls = MuSGD if self.mup else optim.SGD + return opt_cls( + self.parameters(), + lr=self.learning_rate, + momentum=self.momentum, + weight_decay=self.weight_decay, + ) + + def forward(self, x: Tensor) -> Tensor: + x = (x - self.mean_device) / self.std_device + return self.net(x) \ No newline at end of file diff --git a/mdl/sweep.py b/mdl/sweep.py index 405ab5f..ff51913 100644 --- a/mdl/sweep.py +++ b/mdl/sweep.py @@ -1,24 +1,14 @@ -import math -from copy import deepcopy from dataclasses import dataclass, field -from functools import partial from itertools import accumulate from typing import Any, Callable, NamedTuple, Type import torch from scipy.optimize import curve_fit -from torch import Tensor, nn, optim -from torch.func import functional_call, stack_module_state, vmap -from torch.nn.functional import ( - binary_cross_entropy_with_logits as bce_with_logits, -) -from torch.nn.functional import ( - cross_entropy, -) +from torch import Tensor from tqdm.auto import tqdm from .math import partition_logspace -from .mlp_probe import MlpProbe +from .mlp_probe import ResMlpProbe, Probe class PowerLaw(NamedTuple): @@ -42,7 +32,7 @@ class MdlResult: """Number of samples used for each chunk.""" total_trials: int - """Total number of trials used for the estimation.""" + """(DEPRECATED) Total number of trials used for the estimation.""" def scaling_law(self) -> PowerLaw: """Fits a power law to the cross-entropy curve.""" @@ -58,22 +48,13 @@ class Sweep: num_features: int num_classes: int = 2 - num_trials: int = 1 - """Minimum number of trials to use for each chunk size.""" - num_chunks: int = 10 """Number of logarithmically-spaced chunks to split the data into.""" batch_size: int = 32 """Batch size to use for fitting the probes.""" - optimizer_cls: Type[optim.Optimizer] = optim.AdamW - """Optimizer class to use for fitting the probes.""" - - optimizer_kwargs: dict[str, Any] = field(default_factory=dict) - """Keyword arguments to pass to the optimizer constructor.""" - - probe_cls: Type[nn.Module] = MlpProbe + probe_cls: Type[Probe] = ResMlpProbe """Probe class to instantiate.""" probe_kwargs: dict[str, Any] = field(default_factory=dict) @@ -82,48 +63,32 @@ class Sweep: val_frac: float = 0.2 """Fraction of each chunk to use for validation.""" + logger: Any | None = None + + # name: str | None = None + device: str | torch.device = "cpu" dtype: torch.dtype | None = None + ckpt_every: int | None = None + def __post_init__(self): assert self.num_features > 0 assert self.num_classes > 1 - def build_optimizer( - self, n: int - ) -> tuple[optim.Optimizer, Callable[[Tensor], Tensor]]: - probes = [ - self.probe_cls( - num_features=self.num_features, - num_classes=self.num_classes, - device=self.device, - dtype=self.dtype, - **self.probe_kwargs, - ) - for _ in range(n) - ] - params, buffers = stack_module_state(probes) # type: ignore - - fwd = partial(functional_call, probes[0]) - fwd = partial(vmap(fwd), (params, buffers)) - opt = self.optimizer_cls(params.values(), **self.optimizer_kwargs) - - return opt, fwd - - def run(self, x: Tensor, y: Tensor, seed: int = 0) -> MdlResult: + def run( + self, + x: Tensor, + y: Tensor, + seed: int = 0, + transform: Callable[[Tensor, Tensor], Tensor] = lambda x, _: x, + **fit_kwargs, + ) -> MdlResult: N, d = len(x), self.num_features - rng = torch.Generator(device=self.device).manual_seed(seed) + rng = torch.Generator(device=x.device).manual_seed(seed) val_size = min(2048, round(N * self.val_frac)) - test_size = min(2048, round(N * self.val_frac)) - train_size = N - val_size - test_size - - # Shuffle data - indices = torch.randperm(len(x), device=self.device, generator=rng) - x, y = x[indices], y[indices] - - train_x, val_x, test_x = x.split([train_size, val_size, test_size]) - train_y, val_y, test_y = y.split([train_size, val_size, test_size]) + nonval_size = N - val_size # Determining the appropriate size for the smallest chunk is a bit tricky. We # want to make sure that we have enough data for at least two minibatches @@ -131,21 +96,28 @@ def run(self, x: Tensor, y: Tensor, seed: int = 0) -> MdlResult: min_size = min(1024, 2 * max(self.batch_size, self.num_classes, d)) # Split data into num_chunks logarithmically spaced chunks - parts = partition_logspace(len(train_x), self.num_chunks, min_size) - cumsizes = list(accumulate(parts)) + parts = partition_logspace(nonval_size, self.num_chunks, min_size) - loss_fn = bce_with_logits if self.num_classes == 2 else cross_entropy + cumsizes = list(accumulate(parts)) + pbar = tqdm( + zip(cumsizes[:-1], cumsizes[1:]), total=len(cumsizes) - 1, unit="scales" + ) curve = [] - pbar = tqdm(cumsizes, unit="scales") total_mdl = 0.0 - total_trials = 0 - for n in pbar: - num_trials = self.num_trials - total_trials += num_trials + for chunk_idx, (n, next_n) in enumerate(pbar): + # Shuffle data + indices = torch.randperm(len(x), device=x.device, generator=rng) + x, y = x[indices], y[indices] + + nonval_x, val_x = x.split([nonval_size, val_size]) + nonval_y, val_y = y.split([nonval_size, val_size]) + + train_size, test_size = nonval_size - parts[-1], parts[-1] + train_x, test_x = nonval_x.split([train_size, test_size]) + train_y, test_y = nonval_y.split([train_size, test_size]) - # Create new optimizer and forward function for this chunk size probe = self.probe_cls( num_features=self.num_features, num_classes=self.num_classes, @@ -153,69 +125,27 @@ def run(self, x: Tensor, y: Tensor, seed: int = 0) -> MdlResult: dtype=self.dtype, **self.probe_kwargs, ) - opt = self.optimizer_cls(probe.parameters(), **self.optimizer_kwargs) - - best_loss = torch.inf - best_state = probe.state_dict() - schedule = optim.lr_scheduler.ReduceLROnPlateau( - opt, - factor=0.5, - patience=0, - threshold=0.01, + probe.fit( + train_x[:n].to(self.device), + train_y[:n].to(self.device), + x_val=val_x.to(self.device), + y_val=val_y.to(self.device), + verbose=False, + transform=transform, + logger=self.logger if chunk_idx == len(pbar) - 1 else None, + ckpt_every=self.ckpt_every if chunk_idx == len(pbar) - 1 else None, + # save_name=self.name if chunk_idx == len(pbar) - 1 else None, + **fit_kwargs, ) - # Train until we don't improve for four epochs - # TODO: Perform early stopping and learning rate annealing separately - # for each trial? - while opt.param_groups[0]["lr"] > opt.defaults["lr"] * 0.5**4: - # Single epoch on the training set - for x_batch, y_batch in zip( - train_x[:n].split(self.batch_size), - train_y[:n].split(self.batch_size), - ): - opt.zero_grad() - - # We just sum the loss across different trials since they don't - # affect one another - loss = loss_fn(probe(x_batch), y_batch) - loss.backward() - opt.step() - - # Evaluate on the validation set - with torch.no_grad(): - # Update learning rate schedule - val_loss = 0.0 - - for x_batch, y_batch in zip( - val_x.split(self.batch_size), val_y.split(self.batch_size) - ): - loss = loss_fn(probe(x_batch), y_batch, reduction="sum") - val_loss += float(loss) / math.log(2) # Average over trials - - val_loss /= val_size - schedule.step(val_loss) - - if val_loss < best_loss: - best_loss = val_loss - best_state = deepcopy(probe.state_dict()) - - # Evaluate on the next chunk - with torch.no_grad(): - probe.load_state_dict(best_state) - test_loss = 0.0 - - for x_batch, y_batch in zip( - test_x.split(self.batch_size), test_y.split(self.batch_size) - ): - loss = loss_fn(probe(x_batch), y_batch, reduction="sum") - test_loss += float(loss) / math.log(2) # Average over trials - - test_loss /= test_size - - curve.append(float(test_loss)) - pbar.set_postfix(loss=f"{test_loss:.4f}") - - # Update MDL estimate - total_mdl += n * test_loss - - return MdlResult(total_mdl / len(test_x), curve, cumsizes, total_trials) + # Compute test loss and add to scaling curve + test_loss = probe.evaluate( + transform(test_x.to(self.device), test_y.to(self.device)), test_y.to(self.device), self.batch_size + ) + curve.append(float(test_loss)) + pbar.set_postfix(loss=f"{test_loss:.4f}") + + # Update MDL estimate + total_mdl += next_n * test_loss + + return MdlResult(total_mdl / nonval_size, curve, cumsizes, 0) diff --git a/mdl/vision_probe.py b/mdl/vision_probe.py index 9ec8d61..cd238b7 100644 --- a/mdl/vision_probe.py +++ b/mdl/vision_probe.py @@ -1,29 +1,58 @@ +import math import torch import torchvision as tv -from torch import Tensor, optim +from torch import Tensor, nn, optim +from transformers import ( + ConvNextV2Config, ConvNextV2ForImageClassification, + SwinForImageClassification, SwinConfig +) +from mup import MuReadout, MuAdam, MuSGD, load_base_shapes, set_base_shapes +from schedulefree import AdamWScheduleFree from .probe import Probe - + class VisionProbe(Probe): """Probe based on a TorchVision model. Defaults to ResNet-18.""" def __init__( self, - num_features: int, num_classes: int = 2, - transform_size: int = 32, learning_rate: float = 0.005, momentum: float = 0.9, weight_decay: float = 5e-4, model: str = "resnet18", device: str | torch.device | None = None, dtype: torch.dtype | None = None, + *, + num_features: int = 3, # Unused + pretrained: bool = False, + base_shapes_path: str | None = None, + **kwargs ): super().__init__(num_features, num_classes, device, dtype) - net = tv.models.get_model(model, num_classes=num_classes) + if not pretrained: + net = tv.models.get_model(model, num_classes=num_classes) + else: + net = tv.models.resnet18(pretrained=pretrained) + net.fc = nn.Linear(net.fc.in_features, num_classes) + self.net = net.to(device=device, dtype=dtype) # type: ignore + if model == "resnet18": + self.net.conv1 = torch.nn.Conv2d( + 3, + 64, + kernel_size=3, + stride=1, + padding="same", + bias=False, + device=device, + dtype=dtype, + ) + self.net.maxpool = torch.nn.Identity(device=device, dtype=dtype) + + self.mup = base_shapes_path is not None self.learning_rate = learning_rate self.momentum = momentum self.weight_decay = weight_decay @@ -31,30 +60,196 @@ def __init__( (0.485, 0.456, 0.406), (0.229, 0.224, 0.225), ) - self.train_augmentor = tv.transforms.Compose( - [ - tv.transforms.RandomHorizontalFlip(), - tv.transforms.RandomCrop( - transform_size - ), # TODO: Make this configurable - ] - ) - self.test_augmentor = tv.transforms.Compose( - [ - tv.transforms.CenterCrop(transform_size), - ] - ) + if model == "resnet18" and not pretrained: + net.conv1 = nn.Conv2d( + 3, + 64, + 3, + stride=1, + padding="same", + bias=False, + device=device, + dtype=dtype, + ) + net.maxpool = nn.Identity() def build_optimizer(self) -> optim.Optimizer: - return optim.SGD( + opt_cls = MuSGD if self.mup else optim.SGD + return opt_cls( self.parameters(), lr=self.learning_rate, momentum=self.momentum, weight_decay=self.weight_decay, ) - def augment_data(self, x: Tensor) -> Tensor: - return self.train_augmentor(x) if self.training else self.test_augmentor(x) - def forward(self, x: Tensor) -> Tensor: return self.net(self.norm(x)) + + +class ConvNextProbe(Probe): + def __init__( + self, + num_features: int, + num_classes: int = 2, + num_layers: int = 2, + hidden_size: int = 2, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, + arch: str | None = "atto", + **kwargs + ): + from transformers import ConvNextV2Config, ConvNextV2ForImageClassification + + super().__init__(num_features, num_classes, device, dtype) + + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + + match arch: + case "atto" | "": # default + depths = [2, 2, 6, 2] + hidden_sizes = [40, 80, 160, 320] + case "femto": + depths = [2, 2, 6, 2] + hidden_sizes = [48, 96, 192, 384] + case "pico": + depths = [2, 2, 6, 2] + hidden_sizes = [64, 128, 256, 512] + case "nano": + depths = [2, 2, 8, 2] + hidden_sizes = [80, 160, 320, 640] + case "tiny": + depths = [3, 3, 9, 3] + hidden_sizes = [96, 192, 384, 768] + case other: + raise ValueError(f"Unknown ConvNeXt architecture {other}") + + image_size = int(math.sqrt(num_features // 3)) + + cfg = ConvNextV2Config( + image_size=image_size, + depths=depths, + drop_path_rate=0.1, + hidden_sizes=hidden_sizes, + num_labels=num_classes, + # The default of 4 x 4 patches shrinks the image too aggressively for + # low-resolution images like CIFAR-10 + patch_size=1, + ) + self.net = ConvNextV2ForImageClassification(cfg).to(device=device, dtype=dtype) # type: ignore + + # Configure MuP + self.net.classifier = MuReadout( + self.net.classifier.in_features, + self.net.classifier.out_features, + device=device, + dtype=dtype, + readout_zero_init=True + ) + + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + + def build_optimizer(self): + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam(self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas) + return opt_cls(self.parameters(), lr=self.learning_rate, betas=self.betas) + + def forward(self, x): + return self.net(x).logits + + +class SwinProbe(Probe): + def __init__( + self, + num_features: int, + num_classes: int = 2, + num_layers: int = 2, + hidden_size: int = 2, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, + arch: str | None = "atto", + **kwargs + ) -> None: + from torchvision.models.swin_transformer import ( + PatchMergingV2, + SwinTransformer, + SwinTransformerBlockV2, + ) + + super().__init__(num_features, num_classes, device, dtype) + + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + + match arch: + case "atto": + num_heads = [2, 4, 8, 16] + embed_dim = 40 + case "femto": + num_heads = [2, 4, 8, 16] + embed_dim = 48 + case "pico": + num_heads = [2, 4, 8, 16] + embed_dim = 64 + case "nano": + num_heads = [2, 4, 8, 16] + embed_dim = 80 + case "tiny" | "": # default + num_heads = [3, 6, 12, 24] + embed_dim = 96 + case other: + raise ValueError(f"Unknown Swin architecture {other}") + + # Tiny architecture with 2 x 2 patches + self.net = SwinTransformer( + patch_size=[2, 2], + embed_dim=embed_dim, + depths=[2, 2, 6, 2], + num_heads=num_heads, + window_size=[7, 7], + num_classes=num_classes, + stochastic_depth_prob=0.2, + block=SwinTransformerBlockV2, + downsample_layer=PatchMergingV2, + ) + # Configure MuP + self.net.head = MuReadout( + self.net.head.in_features, + self.net.head.out_features, + device=device, + dtype=dtype, + readout_zero_init=True + ) + + self.net = torch.compile(self.net).to(device=device, dtype=dtype) + + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + def build_optimizer(self): + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam(self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas) + return torch.optim.AdamW(self.parameters(), lr=self.learning_rate, betas=self.betas) + + def forward(self, x): + return self.net(x) \ No newline at end of file diff --git a/mdl/vision_probe_old.py b/mdl/vision_probe_old.py new file mode 100644 index 0000000..825bda2 --- /dev/null +++ b/mdl/vision_probe_old.py @@ -0,0 +1,156 @@ +import math +import torch +import torchvision as tv +from torch import Tensor, nn, optim +from transformers import ( + ConvNextV2Config, ConvNextV2ForImageClassification, + SwinForImageClassification, SwinConfig +) +from mup import MuReadout, MuAdam, load_base_shapes, set_base_shapes +from schedulefree import AdamWScheduleFree + +from .probe import Probe + + +class ConvNextProbe(Probe): + def __init__( + self, + num_features: int, + num_classes: int = 2, + num_layers: int = 2, + hidden_size: int = 2, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, + **kwargs + ): + super().__init__(num_features, num_classes, device, dtype) + + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + + depths = [1, 1, 3, 1] + depths = [depth * num_layers for depth in depths] + + hidden_sizes = [hidden_size] + [hidden_size * 2 ** i for i in range(1, 4)] + + image_size = int(math.sqrt(num_features // 3)) + + cfg = ConvNextV2Config( + image_size=image_size, + num_channels=3, + depths=depths, + drop_path_rate=0.1, + hidden_sizes=hidden_sizes, + num_labels=num_classes, + # The default of 4 x 4 patches shrinks the image too aggressively for + # low-resolution images like CIFAR-10 + patch_size=1, + ) + + self.net = ConvNextV2ForImageClassification(cfg).to(device=device, dtype=dtype) + self.net = torch.compile(self.net) + + # Configure MuP + self.net.classifier = MuReadout( + self.net.classifier.in_features, + self.net.classifier.out_features, + device=device, + dtype=dtype, + readout_zero_init=True + ) + + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + + def build_optimizer(self): + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam(self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas) + return opt_cls(self.parameters(), lr=self.learning_rate, betas=self.betas) + + def forward(self, x): + return self.net(x).logits + + +class SwinProbe(Probe): + def __init__( + self, + num_features: int, + num_classes: int = 2, + num_layers: int = 2, + hidden_size: int = 2, + device: str | torch.device | None = None, + dtype: torch.dtype | None = None, + *, + learning_rate: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + schedule_free: bool = False, + base_shapes_path: str | None = None, + **kwargs + ): + assert num_features == 3 * 32 * 32 + super().__init__(num_features, num_classes, device, dtype) + + self.learning_rate = learning_rate + self.betas = betas + self.schedule_free = schedule_free + self.mup = base_shapes_path is not None + + # depths=[1, 2, 1] seen in a gist somewhere + depths = [1, 1, 2] + depths = [depth * num_layers for depth in depths] + + # num_heads=[2, 2, 4] seen in a gist somewhere + num_heads = [1, 1, 2] + num_heads = [num_head * num_layers for num_head in num_heads] + + hidden_sizes = [num_heads[0] * hidden_size * 2**i for i in range(3)] + + cfg = SwinConfig( + image_size=32, + num_channels=3, + depths=depths, + drop_path_rate=0.1, + hidden_sizes=hidden_sizes, + num_labels=num_classes, + embed_dim=num_heads[0] * 4, # Can scale this and the hidden_sizes * 4 arbitrarily + num_heads=num_heads, + # The default of 4 x 4 patches shrinks the image too aggressively for + # low-resolution images like CIFAR-10 + patch_size=2, + window_size=2, + ) + + self.net = SwinForImageClassification(cfg).to(device=device, dtype=dtype) + self.net = torch.compile(self.net) + + # Configure MuP + self.net.classifier = MuReadout( + self.net.classifier.in_features, + self.net.classifier.out_features, + device=device, + dtype=dtype, + readout_zero_init=True + ) + + if base_shapes_path: + base_shapes = load_base_shapes(base_shapes_path) + set_base_shapes(self, base_shapes) + + def build_optimizer(self): + opt_cls = AdamWScheduleFree if self.schedule_free else optim.AdamW + if self.mup: + return MuAdam(self.parameters(), opt_cls, lr=self.learning_rate, betas=self.betas) + return torch.optim.AdamW(self.parameters(), lr=self.learning_rate, betas=self.betas) + + def forward(self, x): + return self.net(x).logits \ No newline at end of file diff --git a/tests/.pre-commit-config.yaml b/tests/.pre-commit-config.yaml new file mode 100644 index 0000000..064824f --- /dev/null +++ b/tests/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files +- repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black +- repo: https://github.com/charliermarsh/ruff-pre-commit + rev: 'v0.0.262' + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] +- repo: https://github.com/codespell-project/codespell + rev: v2.2.4 + hooks: + - id: codespell diff --git a/tests/test_alf_qleace.py b/tests/test_alf_qleace.py new file mode 100644 index 0000000..0719ad1 --- /dev/null +++ b/tests/test_alf_qleace.py @@ -0,0 +1,134 @@ +from argparse import ArgumentParser +from pathlib import Path + +import torch +import torch.nn.functional as F +from concept_erasure.quadratic import QuadraticFitter +from concept_erasure.leace import LeaceFitter +from concept_erasure.alf_qleace import AlfQLeaceFitter +from torch import Tensor +from tqdm.auto import tqdm +import lovely_tensors as lt + +from experiments.cli import get_cifar10, get_cifarnet, IdentityEraser + + +def get_alf_qleace(target_erasure=0.999, shrinkage=True): + state_path = Path("data") / "erasers_cache" / f"alf_qleace.pth" + state_path.parent.mkdir(exist_ok=True) + state = {} if not state_path.exists() else torch.load(state_path, weights_only=False) + + key = f'alf_qleace_{target_erasure}_s={shrinkage}' + if key not in state or args.nocache: + fitter = AlfQLeaceFitter( + num_features, k, dtype=dtype, device=device, shrinkage=shrinkage, target_erasure=target_erasure + ) + + Y_tensor = (F.one_hot(Y_train, k)).to(device) + X_tensor = X_train.flatten(1).to(device).to(dtype) + fitter.update(X_tensor, Y_tensor) + + if args.dataset == "cifarnet": + fitter = fitter.to("cpu") + + state[key] = fitter.eraser + torch.save(state, state_path) + + return state[key] + + +def get_erasers(): + # Populate eraser cache using training data + state_path = Path("data") / "erasers_cache" / f"{args.dataset}_{dtype}_state.pth" + state_path.parent.mkdir(exist_ok=True) + state = {} if not state_path.exists() else torch.load(state_path, weights_only=False) + + for eraser_str in ["leace", "alf_qleace"]: + if eraser_str not in state or args.nocache: + cls = { + "leace": LeaceFitter, + "qleace": QuadraticFitter, + "alf_qleace": AlfQLeaceFitter, + }[eraser_str] + + fitter = cls( + num_features, k, dtype=dtype, device=device, shrinkage=True + ) + + Y_tensor = ( + F.one_hot(Y_train, k) + ).to(device) + X_tensor = X_train.flatten(1).to(device).to(dtype) + fitter.update(X_tensor, Y_tensor) + + if args.dataset == "cifarnet": + fitter = fitter.to("cpu") + + state[eraser_str] = fitter.eraser + + return state['leace'], state['alf_qleace'] + +if __name__ == "__main__": + lt.monkey_patch() + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + dtype = torch.float32 + + parser = ArgumentParser() + parser.add_argument("--dataset", type=str, choices=("cifar10", "cifarnet"), default="cifar10") + parser.add_argument("--nocache", action="store_true") + args = parser.parse_args() + + (X_train, Y_train, _, _, k, X, Y) = { + "cifar10": get_cifar10('cuda'), + "cifarnet": get_cifarnet(), + }[args.dataset] + num_features = X.shape[1] * X.shape[2] * X.shape[3] + + leace_eraser, alf_qleace_eraser = get_erasers() + alf_qleace_eraser = get_alf_qleace(target_erasure=0.999) + + leace_erased: Tensor = leace_eraser.to(X_train.device)(X_train.reshape(len(X_train), -1)).reshape(X_train.shape) + alf_qleace_erased: Tensor = alf_qleace_eraser(X_train.reshape(len(X_train), -1)).reshape(X_train.shape) + + for erased_data, eraser_str in zip([leace_erased, alf_qleace_erased], ["leace", "alf_qleace"]): + mean_barycenter = erased_data.mean(0) + covariance_barycenter = torch.cov(erased_data.flatten(1).T) + class_means = [erased_data[Y_train == c].mean(0) for c in Y_train.unique()] + class_covariances = [erased_data[Y_train == c].flatten(1).T.cov() for c in Y_train.unique()] + + class_covariance_diffs = [covariance_barycenter - class_cov for class_cov in class_covariances] + class_mean_diffs = [mean_barycenter - class_mean for class_mean in class_means] + + print("Eraser: ", eraser_str) + print("Class means distance from barycenter after erasure:", [class_mean_diff.norm() for class_mean_diff in class_mean_diffs]) + print("Class covs diffs from barycenter after erasure:", [class_cov_diff.norm() for class_cov_diff in class_covariance_diffs]) + + max_mean_diff = torch.stack([ + (mean_barycenter - other_mean).flatten().norm() + for other_mean in class_means + ]).max() + max_pixel_diff = torch.stack([ + (mean_barycenter - other_mean).flatten().abs().max() + for other_mean in class_means + ]).max() + print("Max mean difference from barycenter norm", max_mean_diff.item()) + print("Max pixel difference from barycenter", max_pixel_diff.item()) + + max_diff_from_cov_center = torch.stack([ + (class_covariances[i] - covariance_barycenter).flatten().norm() + for i in range(len(class_covariances)) + ]).max() + max_pixel_diff_from_cov_center = torch.stack([ + (class_covariances[i] - covariance_barycenter).flatten().abs().max() + for i in range(len(class_covariances)) + ]).max() + print("Max covariance difference from barycenter norm", max_diff_from_cov_center.item()) + print("Max covariance difference from barycenter pixel", max_pixel_diff_from_cov_center.item()) + + # Average std of each pixel across the unerased data + unerased_std = X_train.std(dim=0).mean() + print(f"Unerased std: {unerased_std:.2f}") + + # Average std of each pixel across the erased data + std = erased_data.std(dim=0).mean() + print(f"{eraser_str} std: {std:.2f}") \ No newline at end of file