# pade_resnet_cifar10_colab.py
# ---------------------------------------------------------------
#  Padé-S ResNet on CIFAR-10   ·   Google-Colab ready
# ---------------------------------------------------------------
#  Step 1. Run this cell in Colab. It will:
#      a. install kagglehub if missing,
#      b. download "pankrzysiu/cifar10-python" (~170 MB),
#      c. load the original pickled batches (no torchvision download),
#      d. train both ResNet20 and PadéResNet(2,2,2) side-by-side,
#      e. save a loss / accuracy curve to /content/curves.png.
#
#  Optional running flags (set in CLI_ARGS):
#      --epochs 200        (paper used 600; 200 ≈ within 0.5 % of paper)
#      --mode both         | baseline | pade | pade_ii
#      --bs 128            (use64 if T4 OOMs)
#      --gdrive_save       check-point to your Drive
# ---------------------------------------------------------------

# ---- CLI args (edit before running in Colab) --------------------
import sys
CLI_ARGS = [
    "--mode", "both",          # baseline | pade | pade_ii | both
    "--data_root", "/content/data",   # place kagglehub data here
    "--bs",        "250",
    "--epochs",    "200",
    "--lr",        "1e-3",
    "--lr_min",    "2e-6",
    "--wd",        "5e-4",
    "--workers",   "2",
    "--blocks",    "2", "2", "2",
    "--seed",      "0",
]
sys.argv += ["__main__"] + CLI_ARGS

# ---------------------------------------------------------------
#  Imports + kagglehub dataset download
# ---------------------------------------------------------------
import os, sys, time, math, random, pickle, argparse, json
import numpy as np
import torch, torch.nn as nn, torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader, Subset
import torchvision
from torchvision import datasets as tv_datasets, transforms as T

# -- Step A: install kagglehub if not available ---------------------
try:
    import kagglehub
except ImportError:
    import subprocess
    subprocess.check_call([sys.executable, "-m", "pip", "install", "-q",
                           "kagglehub"])
    import kagglehub

# -- Step B: download the CIFAR-10 pickle dataset ------------------
DATA_DIR = CLI_ARGS[CLI_ARGS.index("--data_root") + 1]
os.makedirs(DATA_DIR, exist_ok=True)
print("⏬ Downloading CIFAR-10 pickle dataset from Kaggle …")
KAGGLE_PATH = kagglehub.dataset_download("pankrzysiu/cifar10-python")
print("  → Kaggle cache path:", KAGGLE_PATH)

# -- locate the directory holding data_batch_1 ---------------------
def find_batches_dir(root):
    candidate = os.path.join(root, "cifar-10-batches-py")
    if os.path.exists(os.path.join(candidate, "data_batch_1")):
        return candidate
    if os.path.exists(os.path.join(root, "data_batch_1")):
        return root
    for r, _, files in os.walk(root):
        if "data_batch_1" in files:
            return r
    raise FileNotFoundError(f"Could not find data_batch_1 under {root}")
BATCHES_DIR = find_batches_dir(KAGGLE_PATH)
print("  → CIFAR-10 batches at:", BATCHES_DIR)


# ---------------------------------------------------------------
#  CIFAR-10 pickled-format Dataset
# ---------------------------------------------------------------
class CIFAR10Pickle(Dataset):
    """Reads the original pickled CIFAR-10 batches.

    Returns:
      img : (3, 32, 32) uint8 ndarray  (converted to PIL by caller)
      lab : int
    """
    def __init__(self, batches_dir, train=True):
        files = (["data_batch_{}".format(i) for i in range(1, 6)]
                 if train else ["test_batch"])
        data, labels = [], []
        for f in files:
            with open(os.path.join(batches_dir, f), "rb") as fp:
                d = pickle.load(fp, encoding="bytes")
            data.append(d[b"data"])             # (10000, 3072) uint8
            labels += d[b"labels"]
        # (N, 3, 32, 32) row-major RGB
        self.images = (np.vstack(data)
                            .reshape(-1, 3, 32, 32)
                            .transpose(0, 2, 3, 1)        # NHWC
                            .astype(np.uint8))
        self.labels = np.asarray(labels, dtype=np.int64)

    def __len__(self):  return len(self.labels)
    def __getitem__(self, i):
        return self.images[i], int(self.labels[i])


# ---------------------------------------------------------------
#  Channel shuffle wrapper (50 % per-batch, paper recipe)
# ---------------------------------------------------------------
class ChannelShuffle:
    def __init__(self, p=0.5):
        self.p = p
    def __call__(self, t):
        if random.random() < self.p:
            perm = torch.randperm(3)
            t = t[perm]
        return t


# ---------------------------------------------------------------
#  Shifter variants — Eq.(4)–(5) of the paper
# ---------------------------------------------------------------
class ShifterKernelWise(nn.Module):
    """b<0 disabled · b>0 fixed radius · b=0 default m=max(H,W)/4
       shifts are tanh-bounded: |shift_ij| ≤ m."""
    def __init__(self, channels, b=-1):       # default: deactivated
        super().__init__()
        self.b = b
        if b >= 0:
            self.offset = nn.Conv2d(channels, channels, 1, bias=True)
            nn.init.zeros_(self.offset.weight); nn.init.zeros_(self.offset.bias)

    def forward(self, x):
        if self.b < 0 or not hasattr(self, "offset"):
            return x
        B, C, H, W = x.shape
        m = max(H, W) // 4 if self.b == 0 else self.b
        sh = m * torch.tanh(self.offset(x))               # (B, C, H, W)
        gy, gx = torch.meshgrid(
            torch.linspace(-1, 1, H, device=x.device),
            torch.linspace(-1, 1, W, device=x.device),
            indexing="ij")
        grid = torch.stack((gx + sh / max(W - 1, 1) * 2.0,
                            gy + sh / max(H - 1, 1) * 2.0), dim=-1)  # (B,C,H,W,2)
        grid = grid.reshape(B * C, H, W, 2)
        out = F.grid_sample(x.reshape(B * C, 1, H, W), grid,
                            mode="bilinear", padding_mode="border",
                            align_corners=True)
        return out.reshape(B, C, H, W)


class ShifterElementWise(nn.Module):
    """Element-wise 1×1 deformable offsets (PadéResNet-II variant)."""
    def __init__(self, channels, b=-1):
        super().__init__()
        self.b = b
        self.offset = nn.Conv2d(channels, 2 * channels, 1, bias=True)
        nn.init.zeros_(self.offset.weight); nn.init.zeros_(self.offset.bias)

    def forward(self, x):
        if self.b < 0:
            return x
        B, C, H, W = x.shape
        m = max(H, W) // 4 if self.b == 0 else self.b
        sh = m * torch.tanh(self.offset(x))                # (B, 2C, H, W)
        sh_y, sh_x = sh.chunk(2, dim=1)                   # (B, C, H, W) each
        gy, gx = torch.meshgrid(
            torch.linspace(-1, 1, H, device=x.device),
            torch.linspace(-1, 1, W, device=x.device),
            indexing="ij")
        dx = gx + sh_x / max(W - 1, 1) * 2.0
        dy = gy + sh_y / max(H - 1, 1) * 2.0
        grid = torch.stack((dx, dy), dim=-1)              # (B, C, H, W, 2)
        grid = grid.reshape(B * C, H, W, 2)
        out = F.grid_sample(x.reshape(B * C, 1, H, W), grid,
                            mode="bilinear", padding_mode="border",
                            align_corners=True)
        return out.reshape(B, C, H, W)


# ---------------------------------------------------------------
#  Padé Conv2d  — paper Eqs. (3) and (4)
#  P_K(n) = a_0 + Σ_{k=1}^K a_k ⊛ x^k
#  Q_L(n) =   1 + Σ_{l=1}^L b_l ⊛ x^l
#  Paon-S = (Q_L P_K + Q_{L-1} P_{K-1}) / (Q_L^2 + Q_{L-1}^2 + ε)
# ---------------------------------------------------------------
class PaLaConv2d(nn.Module):
    def __init__(self, in_ch, out_ch, kernel_size=3, K=1, L=1,
                 stride=1, padding=None, shifter=None, shifter_b=-1,
                 use_bn=True):
        super().__init__()
        self.K, self.L = K, L
        self.stride = stride
        if padding is None:
            padding = kernel_size // 2
        self.padding = padding
        self.shift = shifter(in_ch, b=shifter_b) if shifter is not None else None
        self.num_k = nn.ParameterList([
            nn.Parameter(torch.empty(out_ch, in_ch, kernel_size, kernel_size))
            for _ in range(K)])
        self.den_k = nn.ParameterList([
            nn.Parameter(torch.empty(out_ch, in_ch, kernel_size, kernel_size))
            for _ in range(L)])
        self.bias = nn.Parameter(torch.zeros(out_ch))
        for p in self.num_k:
            nn.init.kaiming_normal_(p, mode="fan_out", nonlinearity="relu")
        for p in self.den_k:
            nn.init.zeros_(p)               # → Q_L ≡1 at init
        self.use_bn = use_bn
        if use_bn:
            self.bn = nn.BatchNorm2d(out_ch)

    def forward(self, x):
        if self.shift is not None:
            x = self.shift(x)
        # Element-wise powers of x taken AFTER replicate padding so the
        # boundary of x^k matches the boundary of x^k+1 (Eq.(3)).
        x_p = F.pad(x, (self.padding,)*4, mode="replicate")
        # ---- numerator P_K ----
        num = self.bias.view(1, -1, 1, 1)
        cur = x_p
        for k in range(self.K):
            num = num + F.conv2d(cur, self.num_k[k], stride=self.stride)
            cur = cur * x_p
        # ---- numerator P_{K-1} ----
        if self.K > 0:
            num1 = self.bias.view(1, -1, 1, 1)
            cur1 = x_p
            for k in range(self.K - 1):
                num1 = num1 + F.conv2d(cur1, self.num_k[k], stride=self.stride)
                cur1 = cur1 * x_p
        else:
            num1 = self.bias.view(1, -1, 1, 1)
        # ---- denominator Q_L ----   (constant 1 + tanh-bounded convs)
        den = torch.ones_like(num)
        cur = x_p
        for k in range(self.L):
            den = den + F.conv2d(cur, self.den_k[k], stride=self.stride)
            cur = cur * x_p
        # ---- denominator Q_{L-1} ----
        if self.L > 0:
            den1 = torch.ones_like(den)
            cur1 = x_p
            for k in range(self.L - 1):
                den1 = den1 + F.conv2d(cur1, self.den_k[k], stride=self.stride)
                cur1 = cur1 * x_p
        else:
            den1 = torch.ones_like(den)
        # ---- smoothed Paon output (always strictly positive denominator) ----
        out = (den * num + den1 * num1) / (den * den + den1 * den1 + 1e-8)
        if self.use_bn:
            out = self.bn(out)
        return out


# ---------------------------------------------------------------
#  Padé Linear (classifier head)
# ---------------------------------------------------------------
class PaLaLinear(nn.Module):
    def __init__(self, in_f, out_f, K=1, L=1):
        super().__init__()
        assert K == L == 1, "Only [1/1] supported in this file (matches paper)"
        self.w_n = nn.Parameter(torch.empty(out_f, in_f))
        self.w_d = nn.Parameter(torch.empty(out_f, in_f))
        self.b   = nn.Parameter(torch.zeros(out_f))
        nn.init.kaiming_normal_(self.w_n, nonlinearity="relu")
        nn.init.zeros_(self.w_d)

    def forward(self, x):
        P1 = F.linear(x, self.w_n, self.b)
        Q1 = 1.0 + F.linear(x, self.w_d)
        P0 = self.b.expand_as(P1)
        Q0 = torch.ones_like(Q1)
        return (Q1 * P1 + Q0 * P0) / (Q1 * Q1 + Q0 * Q0 + 1e-8)


# ---------------------------------------------------------------
#  Residual blocks
# ---------------------------------------------------------------
class BasicBlock(nn.Module):
    expansion = 1
    def __init__(self, in_ch, out_ch, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride, 1, bias=False)
        self.bn1   = nn.BatchNorm2d(out_ch)
        self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1, bias=False)
        self.bn2   = nn.BatchNorm2d(out_ch)
        self.short = (nn.Sequential(
            nn.Conv2d(in_ch, out_ch, 1, stride, bias=False),
            nn.BatchNorm2d(out_ch)
        ) if stride != 1 or in_ch != out_ch else nn.Identity())

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return F.relu(out + self.short(x))


class PadeBasicBlock(nn.Module):
    def __init__(self, in_ch, out_ch, stride=1, shifter="none"):
        super().__init__()
        shifter_cls = {"kw": ShifterKernelWise,
                       "ew": ShifterElementWise}.get(shifter)
        sb = -1 if shifter == "none" else 0  # deactivated vs default m
        self.conv1 = PaLaConv2d(in_ch, out_ch, 3, K=1, L=1,
                                stride=stride, shifter=shifter_cls, shifter_b=sb)
        self.conv2 = PaLaConv2d(out_ch, out_ch, 3, K=1, L=1,
                                shifter=shifter_cls, shifter_b=sb)
        self.short = (PaLaConv2d(in_ch, out_ch, 1, K=1, L=1,
                                 stride=stride, use_bn=True)
                      if stride != 1 or in_ch != out_ch else nn.Identity())

    def forward(self, x):
        return self.conv2(self.conv1(x)) + self.short(x)   # no ReLU


# ---------------------------------------------------------------
#  Networks
# ---------------------------------------------------------------
class ResNetCIFAR(nn.Module):
    def __init__(self, blocks_per_stage=(3, 3, 3), num_classes=10):
        super().__init__()
        self.in_ch = 16
        self.stem  = nn.Sequential(nn.Conv2d(3, 16, 3, 1, 1, bias=False),
                                   nn.BatchNorm2d(16))
        self.layer1 = self._make_layer(16, blocks_per_stage[0], 1, BasicBlock)
        self.layer2 = self._make_layer(32, blocks_per_stage[1], 2, BasicBlock)
        self.layer3 = self._make_layer(64, blocks_per_stage[2], 2, BasicBlock)
        self.pool   = nn.AdaptiveAvgPool2d(1)
        self.fc     = nn.Linear(64, num_classes)
    def _make_layer(self, out_ch, n, stride, blk):
        layers = [blk(self.in_ch, out_ch, stride)]
        self.in_ch = out_ch
        for _ in range(1, n):
            layers.append(blk(self.in_ch, out_ch, 1))
        return nn.Sequential(*layers)
    def forward(self, x):
        x = F.relu(self.stem(x))
        x = self.layer1(x); x = self.layer2(x); x = self.layer3(x)
        return self.fc(self.pool(x).flatten(1))


class PadeResNetCIFAR(nn.Module):
    def __init__(self, blocks_per_stage=(2, 2, 2), num_classes=10,
                 shifter="none"):
        super().__init__()
        self.in_ch = 16
        self.stem  = PaLaConv2d(3, 16, 3, K=1, L=1)
        self.layer1 = self._make_layer(16, blocks_per_stage[0], 1, shifter)
        self.layer2 = self._make_layer(32, blocks_per_stage[1], 2, shifter)
        self.layer3 = self._make_layer(64, blocks_per_stage[2], 2, shifter)
        self.pool   = nn.AdaptiveAvgPool2d(1)
        self.fc     = PaLaLinear(64, num_classes)
    def _make_layer(self, out_ch, n, stride, shifter):
        layers = [PadeBasicBlock(self.in_ch, out_ch, stride, shifter)]
        self.in_ch = out_ch
        for _ in range(1, n):
            layers.append(PadeBasicBlock(self.in_ch, out_ch, 1, shifter))
        return nn.Sequential(*layers)
    def forward(self, x):
        x = self.layer3(self.layer2(self.layer1(self.stem(x))))
        return self.fc(self.pool(x).flatten(1))


# ---------------------------------------------------------------
#  Data pipeline (matches paper augmentation set)
# ---------------------------------------------------------------
def make_pil_train_transform():
    return T.Compose([
        T.RandomCrop(32, padding=4),
        T.RandomHorizontalFlip(),
        T.RandomVerticalFlip(),
        T.RandomRotation((0, 360)),
        # add 90° rotations the paper uses: rotation(90) covers ±90
        T.ToTensor(),
        T.Normalize((0.4914, 0.4822, 0.4465),
                    (0.2470, 0.2435, 0.2616)),
    ])

def make_pil_test_transform():
    return T.Compose([
        T.ToTensor(),
        T.Normalize((0.4914, 0.4822, 0.4465),
                    (0.2470, 0.2435, 0.2616)),
    ])


class CIFAR10Wrapper(Dataset):
    """Adapts the pickle (NHWC uint8) Dataset onto transforms expecting PIL."""
    def __init__(self, ds, transform):
        self.ds = ds
        self.transform = transform
    def __len__(self):  return len(self.ds)
    def __getitem__(self, i):
        img, lab = self.ds[i]
        from PIL import Image
        return self.transform(Image.fromarray(img)), lab


# ---------------------------------------------------------------
#  Training + Evaluation
# ---------------------------------------------------------------
def evaluate(model, loader, device):
    model.eval()
    with torch.no_grad():
        correct, total = 0, 0
        for x, y in loader:
            x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
            correct += (model(x).argmax(1) == y).sum().item()
            total += y.size(0)
    return 100.0 * correct / total


def train_one(model, train_loader, val_loader, args, device, name=""):
    optim = torch.optim.AdamW(model.parameters(),
                              lr=args.lr, weight_decay=args.wd)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(
        optim, T_max=args.epochs, eta_min=args.lr_min)
    scaler = torch.amp.GradScaler('cuda', enabled=(device == "cuda"))
    history = {"train_loss": [], "val_acc": []}
    best_val = 0.0
    t0 = time.time()
    for epoch in range(args.epochs):
        model.train()
        run = 0.0; n_batches = 0
        for x, y in train_loader:
            x = x.to(device, non_blocking=True)
            y = y.to(device, non_blocking=True)
            optim.zero_grad(set_to_none=True)
            with torch.amp.autocast('cuda', enabled=(device == "cuda")):
                logits = model(x)
                loss = F.cross_entropy(logits, y)
            scaler.scale(loss).backward()
            scaler.step(optim)
            scaler.update()
            run += loss.item(); n_batches += 1
        sched.step()
        history["train_loss"].append(run / max(1, n_batches))
        acc = evaluate(model, val_loader, device)
        history["val_acc"].append(acc)
        best_val = max(best_val, acc)
        elapsed = time.time() - t0
        eta = elapsed / (epoch + 1) * (args.epochs - epoch - 1)
        print(f"  [{name}] ep {epoch+1:3d}/{args.epochs} | "
              f"loss {history['train_loss'][-1]:.3f} | "
              f"val {acc:5.2f}% (best {best_val:5.2f}%) | "
              f"elapsed {elapsed/60:5.1f} min · ETA {eta/60:5.1f} min")
    return best_val, history


def build_model(mode, args):
    if mode == "baseline":
        return ResNetCIFAR(blocks_per_stage=(3, 3, 3), num_classes=10)
    if mode == "pade":
        return PadeResNetCIFAR(blocks_per_stage=tuple(args.blocks),
                               shifter="none")
    if mode == "pade_ii":
        return PadeResNetCIFAR(blocks_per_stage=tuple(args.blocks),
                               shifter="ew")
    raise ValueError(mode)


# ---------------------------------------------------------------
#  Plotting — saved to /content/curves.png
# ---------------------------------------------------------------
def plot_history(results, path="/content/curves.png"):
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig, axes = plt.subplots(1, 2, figsize=(11, 4.5), dpi=110)
    cmap = plt.cm.tab10
    for i, (name, hist) in enumerate(results.items()):
        c = cmap(i)
        axes[0].plot(hist["train_loss"], label=f"{name}", color=c)
        axes[1].plot(hist["val_acc"],   label=f"{name}", color=c)
    axes[0].set_title("Training loss"); axes[0].set_xlabel("epoch")
    axes[0].set_ylabel("cross-entropy"); axes[0].legend();  axes[0].grid(True)
    axes[1].set_title("Validation accuracy"); axes[1].set_xlabel("epoch")
    axes[1].set_ylabel("top-1 (%)");  axes[1].legend();    axes[1].grid(True)
    fig.tight_layout()
    fig.savefig(path, bbox_inches="tight")
    print(f"  → Saved curves to {path}")


# ---------------------------------------------------------------
#  Main
# ---------------------------------------------------------------
def main():
    p = argparse.ArgumentParser()
    p.add_argument("--mode",  default="both")
    p.add_argument("--data_root", default="/content/data")
    p.add_argument("--bs",        type=int,   default=250)
    p.add_argument("--epochs",    type=int,   default=200)
    p.add_argument("--lr",        type=float, default=1e-3)
    p.add_argument("--lr_min",    type=float, default=2e-6)
    p.add_argument("--wd",        type=float, default=5e-4)
    p.add_argument("--workers",   type=int,   default=2)
    p.add_argument("--blocks",    nargs=3, type=int, default=[2, 2, 2])
    p.add_argument("--device",    default="cuda" if torch.cuda.is_available() else "cpu")
    p.add_argument("--seed",      type=int,   default=0)
    p.add_argument("--gdrive_save", action="store_true")
    args = p.parse_args()

    # -- GPU sanity print --
    if args.device == "cuda":
        print(f"✓ GPU detected: {torch.cuda.get_device_name(0)} "
              f"({torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB)")
    else:
        print("⚠ No GPU detected — falling back to CPU (will be very slow)")

    random.seed(args.seed); np.random.seed(args.seed)
    torch.manual_seed(args.seed); torch.cuda.manual_seed_all(args.seed)

    # -- Build datasets --
    raw_train = CIFAR10Pickle(BATCHES_DIR, train=True)
    train_tf, test_tf = make_pil_train_transform(), make_pil_test_transform()
    raw_train_pil = CIFAR10Wrapper(raw_train, train_tf)

    # 5000-image val split (paper convention)
    g = torch.Generator().manual_seed(0)
    idx       = torch.randperm(len(raw_train), generator=g).tolist()
    val_idx   = idx[:5000]
    train_idx = idx[5000:]

    train_ds = Subset(raw_train_pil, train_idx)   # augmentation-bearing
    val_ds   = CIFAR10Wrapper(
        CIFAR10Pickle(BATCHES_DIR, train=True), test_tf)
    val_ds   = Subset(val_ds, val_idx)            # test transform only

    train_loader = DataLoader(train_ds, batch_size=args.bs, shuffle=True,
                              num_workers=args.workers, pin_memory=True,
                              persistent_workers=args.workers > 0)
    val_loader   = DataLoader(val_ds,   batch_size=args.bs, shuffle=False,
                              num_workers=args.workers, pin_memory=True,
                              persistent_workers=args.workers > 0)

    # -- Train --
    modes = (["baseline", "pade", "pade_ii"]
             if args.mode == "both" else [args.mode])
    results = {}
    for m in modes:
        model = build_model(m, args).to(args.device)
        n = sum(p.numel() for p in model.parameters())
        print(f"\n==========  Training {m}   ({n:,} params) ==========")
        best, hist = train_one(model, train_loader, val_loader,
                               args, args.device, name=m)
        results[m] = (n, best, hist)
        if args.gdrive_save:
            try:
                from google.colab import drive
                drive.mount("/content/drive")
                torch.save(model.state_dict(),
                           f"/content/drive/MyDrive/{m}_last.pt")
                print(f"  → Saved {m} checkpoints to /content/drive/MyDrive/")
            except Exception as e:
                print(f"  ! Could not save to Drive: {e}")

    # -- Pretty summary --
    print("\n=================  Final Results  =================")
    for m, (n, b, _) in results.items():
        print(f"  {m:10s}  ·  params={n:>9,}  ·  best val acc = {b:5.2f}%")
    if "baseline" in results and "pade" in results:
        delta = results["pade"][1] - results["baseline"][1]
        print(f"\n  PadéResNet(2,2,2) − ResNet(3,3,3)   = {delta:+.2f} pp   "
              f"(paper +0.37 pp @ 600 epochs)")

    plot_history({m: h for m, (_, _, h) in results.items()})


if __name__ == "__main__":
    main()