# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/B1. Training.ipynb.

# %% auto 0
__all__ = ['SimpleVisual', 'validate', 'train']

# %% ../nbs/B1. Training.ipynb 2
import io
import time
import random
from pathlib import Path

from fastprogress import progress_bar, master_bar
import fastprogress

import numpy as np
import pylab as plt
import math

import IPython

import torch
import torch.nn as nn
from torch.utils.data.dataloader import DataLoader
from torch.profiler import record_function

import webdataset as wds

torch.backends.cudnn.benchmark = True
torch.backends.cudnn.enabled = True
torch.backends.cuda.matmul.allow_tf32 = True
torch.set_float32_matmul_precision('medium')

# %% ../nbs/B1. Training.ipynb 3
class SimpleVisual:
    def __init__ (self, model, masterbar, total_steps):
        self.model = model
        self.masterbar = masterbar
        self.total_steps = total_steps
        self.epochs = total_steps // masterbar.main_bar.total
        
        gs = plt.GridSpec(2, 1, height_ratios=[3,1])
        graph_fig = plt.figure(figsize=(10,6))
        self.graph_fig = graph_fig
        self.loss_p = graph_fig.add_subplot(gs[0])
        self.lr_p = graph_fig.add_subplot(gs[1], sharex=self.loss_p)
        self.lr_p.tick_params('x', labelbottom=False)
        self.graph_out = None
        
        self.its = []
        self.train_losses = []
        self.val_losses = []
        self.lr_history = []
            
    def show(self):
        self.start_t = time.time()
        self.masterbar.write(["samples", "train", "val", "time"], table=True)
        self.graph_out = display(self.graph_fig, display_id=True, clear=True)
    
    def hide(self):
        if self.graph_out is not None:
            self.graph_out.update(IPython.display.HTML(''))
    
    def plot(self):
        loss_p, lr_p = self.loss_p, self.lr_p
        loss_p.clear()
        loss_p.plot(self.its, self.train_losses)
        loss_p.plot(self.its, self.val_losses)
        loss_p.set_xlim(0, self.total_steps)
        loss_p.set_yscale('log')
        lr_p.clear()
        lrs = np.array(self.lr_history)
        lr_p.plot(self.its, lrs)
        self.graph_out.update(self.graph_fig)
    
    def add_data(self, it, lr, train_loss, val_los):
        self.its.append(it)
        self.train_losses.append(train_loss)
        self.val_losses.append(val_los)
        self.lr_history.append(lr)
        self.plot()

    def add_table_row(self, it, avg_train_loss, val_loss):
        elapsed_t = time.time() - self.start_t
        self.masterbar.write([it, f"{avg_train_loss:.5f}", f"{val_loss:.5f}", fastprogress.core.format_time(elapsed_t)], table=True)
    
    def on_iter(self, bar, it, avg_train_loss, val_loss):
        epoch = math.ceil(it / self.total_steps * self.epochs)
        bar.comment = f"#{epoch}/{self.epochs} loss: {avg_train_loss:.3f} / {val_loss:.3f}"

# %% ../nbs/B1. Training.ipynb 4
# FIXME: we need to keep this synchronised with the validation code below...
def validate(model, val, half=True, bs=16, drop_last=False, dl_workers=8, device="cuda"):
    if isinstance(val, torch.utils.data.IterableDataset):
        val_loader = wds.WebLoader(val, batch_size=None, num_workers=dl_workers, drop_last=drop_last) \
            .unbatched().shuffle(1024).batched(bs)
    else:
        val_loader = DataLoader(val, batch_size=bs, num_workers=dl_workers, pin_memory=True, drop_last=drop_last)
    
    with torch.no_grad():
        val_loss = 0
        val_samples = 0
        for args in val_loader:
            args = [x.to(device, non_blocking=True) for x in args]
            with torch.autocast(device_type=device, dtype=torch.float16 if half else torch.float32, enabled=device!='cpu'):
                ps, loss = model(*args)
            N = args[0].shape[0]
            val_loss += loss.mean().item() * N
            val_samples += N
        val_loss = val_loss / val_samples

    return val_loss

# %% ../nbs/B1. Training.ipynb 5
def train(checkpoint_path, model, train, val, half=True, bs=16, lr=1e-4, drop_last=False,
          weight_decay=0.1, warmup_steps=10000, epochs=10, clip_gradient_norm=None,
          dl_workers=8, visual_class = SimpleVisual, profiler=None,
          run_valid_every_iters=8000, table_row_every_iters=80000, chkpt_every_iters=None,
          device="cuda", trainable_params=None, callback=None):
    if chkpt_every_iters is None:
        chkpt_every_iters = table_row_every_iters
    
    mb = master_bar(range(epochs))
    if isinstance(train, torch.utils.data.IterableDataset):
        total_steps = epochs * (train.total_samples // bs)
    else:
        total_steps = epochs * len(train) / bs

    visual = visual_class(model, mb, total_steps * bs)
    model.visual = visual
    
    Path(checkpoint_path).mkdir(exist_ok=True)

    if isinstance(train, torch.utils.data.IterableDataset):
#         train_loader = DataLoader(train, batch_size=None, num_workers=dl_workers, pin_memory=True, drop_last=False, shuffle=False)
#         val_loader = DataLoader(val, batch_size=None, num_workers=dl_workers, pin_memory=True, drop_last=False)
        train_loader = wds.WebLoader(train, batch_size=None, num_workers=dl_workers, drop_last=drop_last) \
            .unbatched().shuffle(1024).batched(bs, partial=False)
        val_loader = wds.WebLoader(val, batch_size=None, num_workers=dl_workers, drop_last=drop_last) \
            .unbatched().shuffle(1024).batched(bs)
    else:
        train_loader = DataLoader(train, batch_size=bs, num_workers=dl_workers, pin_memory=True, drop_last=drop_last, shuffle=True)
        val_loader = DataLoader(val, batch_size=bs, num_workers=dl_workers, pin_memory=True, drop_last=drop_last)
    
    val_loss = torch.nan
    avg_train_loss = torch.nan
    
    if hasattr(model, 'setup'):
        model.setup(device)
    
    try:
        scheduler = None

        if trainable_params is None: trainable_params = model.parameters()
        all_params = set(trainable_params)
        customized_params = set()
        groups = []
        group_map = {}
        for name,m in model.named_modules():
            if hasattr(m, 'no_weight_decay') or hasattr(m, 'lr_scale'):
                m_trainable = [x for x in m.parameters() if x in all_params]
                if not m_trainable: continue
                customized_params |= set(m_trainable)
                m_wd = 0 if hasattr(m, 'no_weight_decay') else weight_decay
                m_lr = lr * getattr(m, 'lr_scale', 1)
                group = group_map.get((m_wd, m_lr), None)
                if not group:
                    group = {"params": [], "names": [], "weight_decay": m_wd, "lr": m_lr}
                    groups.append(group)
                    group_map[(m_wd, m_lr)] = group
                group['params'] += m_trainable
                group['names'].append(name)
                
        other_params = all_params - customized_params
        
        if other_params:
            groups = groups + [
                {"names": ["other"], "params": list(other_params), "weight_decay": weight_decay },
            ]

        optimizer = torch.optim.AdamW(lr=lr, betas=(0.9, 0.95), fused=device!='cpu', params=groups)
        model._optimizer = optimizer
        scaler = torch.cuda.amp.GradScaler(enabled=half)
        warmup_scheduler = torch.optim.lr_scheduler.LinearLR(
            optimizer, 1e-3, 1, warmup_steps
        )
        train_scheduler = torch.optim.lr_scheduler.LinearLR(
            optimizer, 1, 1/25, total_steps - warmup_steps
        )
        scheduler = torch.optim.lr_scheduler.SequentialLR(
            optimizer, schedulers=[warmup_scheduler, train_scheduler], milestones=[warmup_steps]
        )
#         pct_start = min(0.3, warmup_steps / total_steps)
#         scheduler = torch.optim.lr_scheduler.OneCycleLR(
#             optimizer, pct_start=pct_start, steps_per_epoch=math.ceil(train.total_samples/bs), epochs=epochs,
#             max_lr=[pg.get('lr', lr) for pg in groups],
#             final_div_factor=25)
        
        it = 0
        next_val_it = it + 50
        next_chkpt_it = chkpt_every_iters
        next_table_it = table_row_every_iters
                
        visual.show()

        running_loss = [0]
        
        for epoch in mb:
            bar = progress_bar(train_loader, total=train.total_samples//bs, parent=mb)
            for args in bar:
                with record_function("forward"):
                    args = [x.to(device, non_blocking=True) for x in args]

                    # zero the parameter gradients
                    optimizer.zero_grad(set_to_none=True)

                    with torch.autocast(device_type=device, dtype=torch.float16 if half else torch.float32, enabled=device!='cpu'):
                        ps, loss = model(*args)
                    loss = loss.mean()

                with record_function("backward"):
                    scaler.scale(loss).backward()

                    if clip_gradient_norm:
                        scaler.unscale_(optimizer)
                        # Since the gradients of optimizer's assigned params are unscaled, clips as usual:
                        torch.nn.utils.clip_grad_norm_(model.parameters(), clip_gradient_norm)

                    scaler.step(optimizer)
                    scaler.update()

                    scheduler.step()

                    if profiler is not None: profiler.step()

                with record_function("running_loss"):
                    running_loss.append(loss.item())
                    running_loss = running_loss[-5:]
                    avg_train_loss = sum(running_loss)/len(running_loss)

                if it >= next_chkpt_it:
                    with record_function("checkpoint"):
                        next_chkpt_it += chkpt_every_iters
                        torch.save(model.state_dict(), f'{checkpoint_path}/{it:08d}.pt')
                    
                if it >= next_val_it:
                    next_val_it += run_valid_every_iters
                    with record_function("validation"):
                        with record_function("model.eval"):
                            model.eval()
                        with torch.no_grad():
                            val_loss = 0
                            val_samples = 0
                            for args in val_loader:
                                args = [x.to(device, non_blocking=True) for x in args]
                                with torch.autocast(device_type=device, dtype=torch.float16 if half else torch.float32, enabled=device!='cpu'):
                                    ps, loss = model(*args)
                                N = args[0].shape[0]
                                val_loss += loss.mean().item() * N
                                val_samples += N
                            val_loss = val_loss / val_samples
                        with record_function("model.train"):
                            model.train()
                    with record_function("plotting"):
                        visual.add_data(it, scheduler.get_last_lr(), avg_train_loss, val_loss)
                
                if it >= next_table_it:
                    visual.add_table_row(it, avg_train_loss, val_loss)
                    next_table_it += table_row_every_iters

                it += bs
                visual.on_iter(bar, it, avg_train_loss, val_loss)
                if callback is not None: callback(it)
    except KeyboardInterrupt:
        mb.write(f"interrupted")
        mb.show()
        pass
    finally:
        visual.add_table_row(it, avg_train_loss, val_loss)
        mb.show()
        visual.hide()
