{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":46105,"databundleVersionId":5087314,"isSourceIdPinned":false},{"sourceType":"datasetVersion","sourceId":15583282,"datasetId":9970172,"databundleVersionId":16515255}],"dockerImageVersionId":31328,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"5a6e6b72","cell_type":"markdown","source":"## Section 1 — Centralized Configuration\n\nThis is the **single source of truth** for every tunable parameter.\nAll downstream cells read exclusively from the  namespace.\nTo switch models or adjust hyperparameters, only edit this cell.","metadata":{}},{"id":"9147a732-fcf1-416d-98cd-2131bf08968b","cell_type":"code","source":"import shutil\nimport os\n\nSRC = \"/kaggle/input/datasets/hassanabdulrazeq/checkpoints-2/BiGRU\"\nDST = \"/kaggle/working/BiGRU\"\n\n# امسح القديم لو موجود (عشان نتأكد 100%)\nif os.path.exists(DST):\n    shutil.rmtree(DST)\n\n# انسخ من input → working\nshutil.copytree(SRC, DST)\n\nprint(\"Checkpoint restored successfully.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:23.630111Z","iopub.execute_input":"2026-04-07T04:44:23.630906Z","iopub.status.idle":"2026-04-07T04:44:23.731629Z","shell.execute_reply.started":"2026-04-07T04:44:23.630872Z","shell.execute_reply":"2026-04-07T04:44:23.730852Z"}},"outputs":[],"execution_count":null},{"id":"370bfaca","cell_type":"code","source":"import os\n\nclass CFG:\n    # ── Model identity ────────────────────────────────────────────────────────\n    MODEL_NAME       = \"BiGRU\"       # Switch between models by changing this value\n\n    # ── Training hyperparameters ──────────────────────────────────────────────\n    EPOCHS           = 100\n    BATCH_SIZE       = 128\n    LEARNING_RATE    = 1e-3\n    MIN_LR           = 1e-6\n    WARMUP_RATIO     = 0.05           # Fraction of total steps used for LR warm-up\n    WEIGHT_DECAY     = 1e-4\n    LABEL_SMOOTHING  = 0.1\n    EARLY_STOP_PAT   = 20             # Early-stopping patience in epochs\n\n    # ── Gradient accumulation ─────────────────────────────────────────────────\n    GRAD_ACCUM_STEPS = 1              # Set > 1 to simulate a larger effective batch\n\n    # ── Precision ─────────────────────────────────────────────────────────────\n    USE_AMP          = True           # Enable mixed-precision (FP16) training\n   \n    RESET_CHECKPOINT = False   # اضبطه True لما تغير الـ architecture\n                              # ارجعه False بعد أول run ناجح\n\n    # ── Reproducibility ───────────────────────────────────────────────────────\n    SEED             = 42\n\n    # ── Data dimensions (must match the Preprocess layer) ────────────────────\n    ROWS_PER_FRAME   = 543\n    MAX_LEN          = 384\n    NUM_CLASSES      = 250\n    PAD_VALUE        = -100.0\n\n    # ── Dataset paths ─────────────────────────────────────────────────────────\n    DATA_DIR         = \"/kaggle/input/competitions/asl-signs\"\n    TRAIN_CSV        = os.path.join(DATA_DIR, \"train.csv\")\n    LANDMARK_DIR     = os.path.join(DATA_DIR, \"train_landmark_files\")\n    SIGN_MAP         = os.path.join(DATA_DIR, \"sign_to_prediction_index_map.json\")\n\n    # ── Output root ───────────────────────────────────────────────────────────\n    WORKING_DIR      = \"/kaggle/working\"\n\n    # ── Logging flags ─────────────────────────────────────────────────────────\n    USE_LOGGING      = True           # Write structured logs to file\n    LOG_LEVEL        = \"INFO\"        # DEBUG | INFO | WARNING | ERROR\n\n    # ── Comparison summary ────────────────────────────────────────────────────\n    COMPARISON_CSV   = os.path.join(WORKING_DIR, \"model_comparison_summary.csv\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:23.732897Z","iopub.execute_input":"2026-04-07T04:44:23.733109Z","iopub.status.idle":"2026-04-07T04:44:23.739348Z","shell.execute_reply.started":"2026-04-07T04:44:23.733088Z","shell.execute_reply":"2026-04-07T04:44:23.73858Z"}},"outputs":[],"execution_count":null},{"id":"3001ead7","cell_type":"markdown","source":"## Section 2 — Library Imports\n\nAll third-party dependencies are imported in one place.\nNo data pipeline code is modified by this cell.","metadata":{}},{"id":"e638312f","cell_type":"code","source":"# Standard library\nimport sys\nimport gc\nimport time\nimport math\nimport random\nimport logging\nimport datetime\nimport traceback\nimport json\nfrom pathlib import Path\n\n# Data and visualisation\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom tqdm.autonotebook import tqdm\n\n# Machine learning\nimport sklearn\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import accuracy_score, f1_score, classification_report\n\n# Deep learning\nimport tensorflow as tf\nfrom tensorflow.keras.models import Model\nfrom tensorflow.keras import layers, metrics, losses, optimizers, mixed_precision\nfrom tensorflow.keras.utils import plot_model\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:23.740253Z","iopub.execute_input":"2026-04-07T04:44:23.740609Z","iopub.status.idle":"2026-04-07T04:44:33.180723Z","shell.execute_reply.started":"2026-04-07T04:44:23.740586Z","shell.execute_reply":"2026-04-07T04:44:33.179747Z"}},"outputs":[],"execution_count":null},{"id":"2c28f50b","cell_type":"markdown","source":"## Section 3 — Logging System\n\nA structured, dual-output logger writes to both a dedicated per-experiment\nlog file and to stdout. This replaces all bare  statements in\ntraining and evaluation code.","metadata":{}},{"id":"48ea3487","cell_type":"code","source":"def setup_logger(model_name, log_dir, level=\"INFO\"):\n    os.makedirs(log_dir, exist_ok=True)\n    timestamp = datetime.datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n    log_file  = os.path.join(log_dir, f\"{model_name}_{timestamp}.log\")\n\n    logger = logging.getLogger(model_name)\n    logger.setLevel(getattr(logging, level.upper(), logging.INFO))\n    logger.handlers.clear()\n\n    fmt = logging.Formatter(\n        \"[%(asctime)s] [%(name)s] [%(levelname)s]  %(message)s\",\n        datefmt=\"%Y-%m-%d %H:%M:%S\",\n    )\n    fh = logging.FileHandler(log_file, encoding=\"utf-8\")\n    fh.setFormatter(fmt)\n    logger.addHandler(fh)\n\n    sh = logging.StreamHandler(sys.stdout)\n    sh.setFormatter(fmt)\n    logger.addHandler(sh)\n\n    logger.propagate = False\n    logger.info(\"Logger initialised — writing to: %s\", log_file)\n    return logger\n\nlogger = logging.getLogger(\"pipeline\")\nlogging.basicConfig(level=logging.INFO)\nlogger.info(\"Temporary root logger active until output directories are ready.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.18298Z","iopub.execute_input":"2026-04-07T04:44:33.184474Z","iopub.status.idle":"2026-04-07T04:44:33.198103Z","shell.execute_reply.started":"2026-04-07T04:44:33.18442Z","shell.execute_reply":"2026-04-07T04:44:33.19712Z"}},"outputs":[],"execution_count":null},{"id":"3bb7b039","cell_type":"markdown","source":"## Section 4 — Reproducibility\n\nFixed random seeds are applied to Python, NumPy, and TensorFlow.\nDeterministic op mode is enabled so that identical runs yield identical results.","metadata":{}},{"id":"813d44f8","cell_type":"code","source":"os.environ[\"PYTHONHASHSEED\"]      = str(CFG.SEED)\nos.environ[\"TF_DETERMINISTIC_OPS\"] = \"1\"\n\nrandom.seed(CFG.SEED)\nnp.random.seed(CFG.SEED)\ntf.random.set_seed(CFG.SEED)\n\nlogger.info(\"Global seed set to %d. Deterministic ops enabled.\", CFG.SEED)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.19975Z","iopub.execute_input":"2026-04-07T04:44:33.200116Z","iopub.status.idle":"2026-04-07T04:44:33.21918Z","shell.execute_reply.started":"2026-04-07T04:44:33.200077Z","shell.execute_reply":"2026-04-07T04:44:33.218236Z"}},"outputs":[],"execution_count":null},{"id":"d5e53755","cell_type":"markdown","source":"## Section 5 — Device Detection and Mixed Precision\n\nGPUs are detected and memory growth is enabled to avoid OOM errors.\nAMP (FP16) is activated when  and a GPU is available,\nhalving memory usage and increasing throughput with no code changes elsewhere.","metadata":{}},{"id":"3c96f4f2","cell_type":"code","source":"gpus = tf.config.list_physical_devices(\"GPU\")\nfor gpu in gpus:\n    tf.config.experimental.set_memory_growth(gpu, True)\n\nif gpus:\n    logger.info(\"GPU(s) detected: %s\", [g.name for g in gpus])\nelse:\n    logger.warning(\"No GPU detected — training will run on CPU.\")\n\nif CFG.USE_AMP and gpus:\n    mixed_precision.set_global_policy(\"mixed_float16\")\n    logger.info(\"Mixed precision policy: mixed_float16\")\nelse:\n    mixed_precision.set_global_policy(\"float32\")\n    logger.info(\"Mixed precision disabled — using float32.\")\n\nlogger.info(\"TensorFlow  : %s\", tf.__version__)\nlogger.info(\"Python      : %s\", sys.version.split()[0])\nlogger.info(\"NumPy       : %s\", np.__version__)\nlogger.info(\"Pandas      : %s\", pd.__version__)\nlogger.info(\"Scikit-Learn: %s\", sklearn.__version__)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.220063Z","iopub.execute_input":"2026-04-07T04:44:33.220328Z","iopub.status.idle":"2026-04-07T04:44:33.579478Z","shell.execute_reply.started":"2026-04-07T04:44:33.220305Z","shell.execute_reply":"2026-04-07T04:44:33.578819Z"}},"outputs":[],"execution_count":null},{"id":"2c534ecd","cell_type":"markdown","source":"## Section 6 — Output Directory Structure\n\nA standardised directory tree is created for every model:\n\n","metadata":{}},{"id":"8c3d4447","cell_type":"code","source":"def build_output_dirs(working_dir, model_name):\n    base = os.path.join(working_dir, model_name)\n    dirs = {\n        \"base\":        base,\n        \"checkpoints\": os.path.join(base, \"checkpoints\"),\n        \"logs\":        os.path.join(base, \"logs\"),\n        \"metrics\":     os.path.join(base, \"metrics\"),\n        \"predictions\": os.path.join(base, \"predictions\"),\n        \"plots\":       os.path.join(base, \"plots\"),\n    }\n    for path in dirs.values():\n        os.makedirs(path, exist_ok=True)\n    return dirs\n\nDIRS = build_output_dirs(CFG.WORKING_DIR, CFG.MODEL_NAME)\n\nlogger = setup_logger(CFG.MODEL_NAME, DIRS[\"logs\"], CFG.LOG_LEVEL)\nlogger.info(\"Output directories ready: %s\", DIRS[\"base\"])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.580614Z","iopub.execute_input":"2026-04-07T04:44:33.581314Z","iopub.status.idle":"2026-04-07T04:44:33.5909Z","shell.execute_reply.started":"2026-04-07T04:44:33.581287Z","shell.execute_reply":"2026-04-07T04:44:33.590026Z"}},"outputs":[],"execution_count":null},{"id":"e45c683d","cell_type":"markdown","source":"## Section 7 — Data Paths\n\nInput paths are derived from  and verified.\n**This cell is part of the protected data pipeline and must not be modified.**","metadata":{}},{"id":"c8458c3b","cell_type":"code","source":"DATA_DIR     = Path(CFG.DATA_DIR)\nTRAIN_CSV    = Path(CFG.TRAIN_CSV)\nLANDMARK_DIR = Path(CFG.LANDMARK_DIR)\nSIGN_MAP     = Path(CFG.SIGN_MAP)\n\nlogger.info(\"DATA_DIR     : %s  exists=%s\", DATA_DIR,     DATA_DIR.exists())\nlogger.info(\"TRAIN_CSV    : %s  exists=%s\", TRAIN_CSV,    TRAIN_CSV.exists())\nlogger.info(\"LANDMARK_DIR : %s  exists=%s\", LANDMARK_DIR, LANDMARK_DIR.exists())\n\nparquet_folders = list(LANDMARK_DIR.glob(\"*\")) if LANDMARK_DIR.exists() else []\nlogger.info(\"Parquet participant folders found: %d\", len(parquet_folders))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.591917Z","iopub.execute_input":"2026-04-07T04:44:33.592262Z","iopub.status.idle":"2026-04-07T04:44:33.608739Z","shell.execute_reply.started":"2026-04-07T04:44:33.592239Z","shell.execute_reply":"2026-04-07T04:44:33.607956Z"}},"outputs":[],"execution_count":null},{"id":"0c4bbd81","cell_type":"markdown","source":"## Section 8 — Raw Data Loading\n\nThe training manifest CSV is loaded and its shape is logged.\n**This cell is part of the protected data pipeline and must not be modified.**","metadata":{}},{"id":"6440c4d0","cell_type":"code","source":"train_df = pd.read_csv(TRAIN_CSV)\ndisplay(train_df.head())\ndisplay(train_df.info())\n\nlogger.info(\"train.csv: %d rows | %d unique signs | %d participants\",\n            len(train_df), train_df[\"sign\"].nunique(), train_df[\"participant_id\"].nunique())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.609655Z","iopub.execute_input":"2026-04-07T04:44:33.610306Z","iopub.status.idle":"2026-04-07T04:44:33.748281Z","shell.execute_reply.started":"2026-04-07T04:44:33.610255Z","shell.execute_reply":"2026-04-07T04:44:33.747451Z"}},"outputs":[],"execution_count":null},{"id":"2c778712","cell_type":"markdown","source":"## Section 9 — Spatial-Temporal Feature Engineering\n\nThis block defines the core preprocessing pipeline:\n\n1. **Landmark index sets** — 543 raw MediaPipe nodes are reduced to the most\n   informative subset (lips, eyes, nose, hands), reducing noise and compute.\n2. **NaN-safe statistics** —  /  handle frames with\n   missing detections without propagating  through the network.\n3. ** Keras layer** — a differentiable, graph-compatible layer that\n   normalises coordinates, computes velocity (first derivative) and acceleration\n   (second derivative) across frames, and concatenates them into a single feature\n   vector of shape .\n\n**This cell is part of the protected data pipeline and must not be modified.**","metadata":{}},{"id":"b682e096","cell_type":"code","source":"# ── Data constants ────────────────────────────────────────────────────────────\nROWS_PER_FRAME = CFG.ROWS_PER_FRAME\nMAX_LEN        = CFG.MAX_LEN\nCROP_LEN       = MAX_LEN\nNUM_CLASSES    = CFG.NUM_CLASSES\nPAD            = CFG.PAD_VALUE\n\n# ── Landmark index definitions ────────────────────────────────────────────────\nNOSE  = [1, 2, 98, 327]\nLNOSE = [98]\nRNOSE = [327]\n\nLIP = [\n    0, 61, 185, 40, 39, 37, 267, 269, 270, 409,\n    291, 146, 91, 181, 84, 17, 314, 405, 321, 375,\n    78, 191, 80, 81, 82, 13, 312, 311, 310, 415,\n    95, 88, 178, 87, 14, 317, 402, 318, 324, 308,\n]\nLLIP = [84, 181, 91, 146, 61, 185, 40, 39, 37, 87, 178, 88, 95, 78, 191, 80, 81, 82]\nRLIP = [314, 405, 321, 375, 291, 409, 270, 269, 267, 317, 402, 318, 324, 308, 415, 310, 311, 312]\n\nPOSE  = [500, 502, 504, 501, 503, 505, 512, 513]\nLPOSE = [513, 505, 503, 501]\nRPOSE = [512, 504, 502, 500]\n\nREYE = [33, 7, 163, 144, 145, 153, 154, 155, 133, 246, 161, 160, 159, 158, 157, 173]\nLEYE = [263, 249, 390, 373, 374, 380, 381, 382, 362, 466, 388, 387, 386, 385, 384, 398]\n\nLHAND = np.arange(468, 489).tolist()\nRHAND = np.arange(522, 543).tolist()\n\nPOINT_LANDMARKS = LIP + LHAND + RHAND + NOSE + REYE + LEYE\n\nNUM_NODES = len(POINT_LANDMARKS)\nCHANNELS  = 6 * NUM_NODES   # (X, Y) x (position, velocity, acceleration)\n\nlogger.info(\"Selected landmark nodes: %d  |  Feature channels: %d\", NUM_NODES, CHANNELS)\n\n\n# ── NaN-safe statistics ───────────────────────────────────────────────────────\ndef tf_nan_mean(x, axis=0, keepdims=False):\n    sum_val   = tf.reduce_sum(tf.where(tf.math.is_nan(x), tf.zeros_like(x), x),    axis=axis, keepdims=keepdims)\n    count_val = tf.reduce_sum(tf.where(tf.math.is_nan(x), tf.zeros_like(x), tf.ones_like(x)), axis=axis, keepdims=keepdims)\n    return sum_val / count_val\n\ndef tf_nan_std(x, center=None, axis=0, keepdims=False):\n    if center is None:\n        center = tf_nan_mean(x, axis=axis, keepdims=True)\n    d = x - center\n    return tf.math.sqrt(tf_nan_mean(d * d, axis=axis, keepdims=keepdims))\n\n\n# ── Preprocess Keras layer ────────────────────────────────────────────────────\nclass Preprocess(tf.keras.layers.Layer):\n    def __init__(self, max_len=MAX_LEN, point_landmarks=POINT_LANDMARKS, **kwargs):\n        super().__init__(**kwargs)\n        self.max_len         = max_len\n        self.point_landmarks = point_landmarks\n\n    def call(self, inputs):\n        if inputs.shape.rank == 3:\n            x = inputs[None, ...]\n        else:\n            x = inputs\n\n        mean = tf_nan_mean(tf.gather(x, [17], axis=2), axis=[1, 2], keepdims=True)\n        mean = tf.where(tf.math.is_nan(mean), tf.constant(0.5, x.dtype), mean)\n\n        x   = tf.gather(x, self.point_landmarks, axis=2)\n        std = tf_nan_std(x, center=mean, axis=[1, 2], keepdims=True)\n        x   = (x - mean) / std\n\n        if self.max_len is not None:\n            x = x[:, :self.max_len]\n\n        length = tf.shape(x)[1]\n        x = x[..., :2]\n\n        dx = tf.cond(\n            tf.shape(x)[1] > 1,\n            lambda: tf.pad(x[:, 1:] - x[:, :-1], [[0,0],[0,1],[0,0],[0,0]]),\n            lambda: tf.zeros_like(x),\n        )\n        dx2 = tf.cond(\n            tf.shape(x)[1] > 2,\n            lambda: tf.pad(x[:, 2:] - x[:, :-2], [[0,0],[0,2],[0,0],[0,0]]),\n            lambda: tf.zeros_like(x),\n        )\n\n        x = tf.concat([\n            tf.reshape(x,   (-1, length, 2 * len(self.point_landmarks))),\n            tf.reshape(dx,  (-1, length, 2 * len(self.point_landmarks))),\n            tf.reshape(dx2, (-1, length, 2 * len(self.point_landmarks))),\n        ], axis=-1)\n\n        x = tf.where(tf.math.is_nan(x), tf.constant(0., x.dtype), x)\n        return x\n\n    def get_config(self):\n        config = super().get_config()\n        config.update({\"max_len\": self.max_len, \"point_landmarks\": self.point_landmarks})\n        return config\n\n\npreprocess_layer = Preprocess(max_len=MAX_LEN, point_landmarks=POINT_LANDMARKS)\nlogger.info(\"Preprocess layer instantiated.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.750829Z","iopub.execute_input":"2026-04-07T04:44:33.751568Z","iopub.status.idle":"2026-04-07T04:44:33.771597Z","shell.execute_reply.started":"2026-04-07T04:44:33.751528Z","shell.execute_reply":"2026-04-07T04:44:33.770723Z"}},"outputs":[],"execution_count":null},{"id":"a497f7b3","cell_type":"markdown","source":"## Section 10 — Data Augmentation and Parquet Pipeline\n\nFive spatiotemporal augmentations improve generalisation:\n\n| Augmentation | Effect |\n|---|---|\n|  | Mirrors hands and face (left ↔ right signer) |\n|  | Randomly alters signing speed |\n|  | Rotation, scale, and shear transforms |\n|  | Blanks a random contiguous time window |\n|  | Blanks a random spatial region per frame |\n\n wraps a Python generator in \nand returns a prefetch-optimised pipeline.\n\n**This cell is part of the protected data pipeline and must not be modified.**","metadata":{}},{"id":"0e7f9149","cell_type":"code","source":"# ── Label encoding ────────────────────────────────────────────────────────────\nif \"label\" not in train_df.columns:\n    sign_list     = sorted(train_df[\"sign\"].unique())\n    sign_to_label = {sign: label for label, sign in enumerate(sign_list)}\n    label_to_sign = {label: sign for sign, label in sign_to_label.items()}\n    train_df[\"label\"] = train_df[\"sign\"].map(sign_to_label)\n    logger.info(\"Encoded %d unique sign classes.\", len(sign_list))\n\n\n# ── Parquet reader ─────────────────────────────────────────────────────────────\ndef load_parquet_video(file_path):\n    try:\n        df     = pd.read_parquet(file_path, columns=[\"x\", \"y\", \"z\"], engine=\"pyarrow\")\n        coords = df.values.astype(np.float32)\n        frames = len(coords) // ROWS_PER_FRAME\n        return coords.reshape(frames, ROWS_PER_FRAME, 3)\n    except Exception:\n        return np.zeros((0, ROWS_PER_FRAME, 3), dtype=np.float32)\n\n\n# ── Augmentation helpers ───────────────────────────────────────────────────────\ndef filter_nans_tf(x, ref_point=POINT_LANDMARKS):\n    mask = tf.math.logical_not(\n        tf.reduce_all(tf.math.is_nan(tf.gather(x, ref_point, axis=1)), axis=[-2, -1])\n    )\n    return tf.boolean_mask(x, mask, axis=0)\n\n\ndef flip_lr(x):\n    x_coord, y_coord, z_coord = tf.unstack(x, axis=-1)\n    x_coord = 1 - x_coord\n    new_x   = tf.stack([x_coord, y_coord, z_coord], -1)\n    new_x   = tf.transpose(new_x, [1, 0, 2])\n    for left, right in [(LHAND, RHAND), (LLIP, RLIP), (LPOSE, RPOSE), (LEYE, REYE), (LNOSE, RNOSE)]:\n        lv    = tf.gather(new_x, left,  axis=0)\n        rv    = tf.gather(new_x, right, axis=0)\n        new_x = tf.tensor_scatter_nd_update(new_x, tf.constant(left) [..., None], rv)\n        new_x = tf.tensor_scatter_nd_update(new_x, tf.constant(right)[..., None], lv)\n    return tf.transpose(new_x, [1, 0, 2])\n\n\ndef interp1d_(x, target_len, method=\"random\"):\n    target_len = tf.maximum(1, target_len)\n    width      = tf.shape(x)[1]\n    size       = [target_len, width]\n    if method == \"random\":\n        rand_val = tf.random.uniform(())\n        if rand_val < 0.33:\n            x = tf.image.resize(x, size, \"bilinear\")\n        elif rand_val < 0.66:\n            x = tf.image.resize(x, size, \"bicubic\")\n        else:\n            x = tf.image.resize(x, size, \"nearest\")\n    else:\n        x = tf.image.resize(x, size, method)\n    return x\n\n\ndef resample(x, rate=(0.8, 1.2)):\n    rate     = tf.random.uniform((), rate[0], rate[1])\n    length   = tf.shape(x)[0]\n    new_size = tf.cast(rate * tf.cast(length, tf.float32), tf.int32)\n    return interp1d_(x, new_size)\n\n\ndef spatial_random_affine(xyz, scale=(0.8, 1.2), shear=(-0.15, 0.15), shift=(-0.1, 0.1), degree=(-30, 30)):\n    center = tf.constant([0.5, 0.5])\n    if scale is not None:\n        xyz = tf.random.uniform((), *scale) * xyz\n    if shear is not None:\n        xy, z   = xyz[..., :2], xyz[..., 2:]\n        shear_x = shear_y = tf.random.uniform((), *shear)\n        if tf.random.uniform(()) < 0.5:\n            shear_x = 0.\n        else:\n            shear_y = 0.\n        shear_mat = tf.identity([[1., shear_x], [shear_y, 1.]])\n        xy        = xy @ shear_mat\n        center    = center + [shear_y, shear_x]\n        xyz       = tf.concat([xy, z], axis=-1)\n    if degree is not None:\n        xy, z   = xyz[..., :2], xyz[..., 2:]\n        xy     -= center\n        radian  = tf.random.uniform((), *degree) / 180 * np.pi\n        c, s    = tf.math.cos(radian), tf.math.sin(radian)\n        rot     = tf.identity([[c, s], [-s, c]])\n        xy      = xy @ rot + center\n        xyz     = tf.concat([xy, z], axis=-1)\n    if shift is not None:\n        xyz = xyz + tf.random.uniform((), *shift)\n    return xyz\n\n\ndef temporal_crop(x, length=MAX_LEN):\n    l      = tf.shape(x)[0]\n    offset = tf.random.uniform((), 0, tf.clip_by_value(l - length, 1, length), dtype=tf.int32)\n    return x[offset: offset + length]\n\n\ndef temporal_mask(x, size=(0.2, 0.4), mask_value=float(\"nan\")):\n    l           = tf.shape(x)[0]\n    mask_size   = tf.cast(tf.cast(l, tf.float32) * tf.random.uniform((), *size), tf.int32)\n    mask_offset = tf.random.uniform((), 0, tf.clip_by_value(l - mask_size, 1, l), dtype=tf.int32)\n    indices     = tf.range(mask_offset, mask_offset + mask_size)[..., None]\n    updates     = tf.fill([mask_size, ROWS_PER_FRAME, 3], mask_value)\n    return tf.tensor_scatter_nd_update(x, indices, updates)\n\n\ndef spatial_mask(x, size=(0.2, 0.4), mask_value=float(\"nan\")):\n    mask_offset_y = tf.random.uniform(())\n    mask_offset_x = tf.random.uniform(())\n    mask_size     = tf.random.uniform((), *size)\n    mask_x        = (mask_offset_x < x[..., 0]) & (x[..., 0] < mask_offset_x + mask_size)\n    mask_y        = (mask_offset_y < x[..., 1]) & (x[..., 1] < mask_offset_y + mask_size)\n    return tf.where((mask_x & mask_y)[..., None], mask_value, x)\n\n\ndef augment_fn(x, max_len=None):\n    if tf.random.uniform(()) < 0.80: x = resample(x, (0.5, 1.5))\n    if tf.random.uniform(()) < 0.50: x = flip_lr(x)\n    if max_len is not None:           x = temporal_crop(x, max_len)\n    if tf.random.uniform(()) < 0.75: x = spatial_random_affine(x)\n    if tf.random.uniform(()) < 0.50: x = temporal_mask(x)\n    if tf.random.uniform(()) < 0.50: x = spatial_mask(x)\n    return x\n\n\ndef process_data(coord, label, augment=False, max_len=MAX_LEN):\n    coord     = filter_nans_tf(coord)\n    if augment:\n        coord = augment_fn(coord, max_len=max_len)\n    coord     = tf.ensure_shape(coord, (None, ROWS_PER_FRAME, 3))\n    processed = preprocess_layer(coord)\n    processed = tf.squeeze(processed, axis=0)\n    processed = tf.cast(processed, tf.float32)\n    return processed, tf.one_hot(label, NUM_CLASSES)\n\n\ndef get_parquet_dataset(df, data_dir=CFG.DATA_DIR, batch_size=CFG.BATCH_SIZE,\n                        max_len=MAX_LEN, augment=False, shuffle=False):\n    def generator():\n        sample_df = df.sample(frac=1).reset_index(drop=True) if shuffle else df\n        for _, row in sample_df.iterrows():\n            file_path = os.path.normpath(\n                os.path.join(data_dir, str(row[\"path\"]).replace(\"\\\\\", \"/\"))\n            )\n            coords = load_parquet_video(file_path)\n            if coords.shape[0] > 0:\n                yield coords, int(row[\"label\"])\n\n    ds = tf.data.Dataset.from_generator(\n        generator,\n        output_signature=(\n            tf.TensorSpec(shape=(None, ROWS_PER_FRAME, 3), dtype=tf.float32),\n            tf.TensorSpec(shape=(),                        dtype=tf.int32),\n        ),\n    )\n    ds = ds.map(\n        lambda x, y: process_data(x, y, augment=augment, max_len=max_len),\n        num_parallel_calls=tf.data.AUTOTUNE,\n    )\n    ds = ds.padded_batch(\n        batch_size,\n        padding_values=(tf.cast(PAD, tf.float32), tf.cast(0.0, tf.float32)),\n        padded_shapes=([max_len, CHANNELS], [NUM_CLASSES]),\n        drop_remainder=True,\n    )\n    ds = ds.repeat()\n    ds = ds.prefetch(tf.data.AUTOTUNE)\n    return ds\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.772546Z","iopub.execute_input":"2026-04-07T04:44:33.772868Z","iopub.status.idle":"2026-04-07T04:44:33.813703Z","shell.execute_reply.started":"2026-04-07T04:44:33.772831Z","shell.execute_reply":"2026-04-07T04:44:33.812901Z"}},"outputs":[],"execution_count":null},{"id":"ce6d6878","cell_type":"markdown","source":"## Section 11 — Pipeline Sanity Check\n\nA small batch is run through the pipeline to verify tensor shapes and dtypes\nbefore any expensive computation begins.","metadata":{}},{"id":"361b764b","cell_type":"code","source":"logger.info(\"Running pipeline sanity check on 10 samples...\")\n_test_ds = get_parquet_dataset(train_df.head(10), batch_size=2, augment=True)\nfor _bx, _by in _test_ds.take(1):\n    logger.info(\"Sanity check X: %s  Y: %s  dtype: %s\", _bx.shape, _by.shape, _bx.dtype)\ndel _test_ds\ngc.collect()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:33.814531Z","iopub.execute_input":"2026-04-07T04:44:33.814905Z","iopub.status.idle":"2026-04-07T04:44:36.031881Z","shell.execute_reply.started":"2026-04-07T04:44:33.814867Z","shell.execute_reply":"2026-04-07T04:44:36.031204Z"}},"outputs":[],"execution_count":null},{"id":"207fd99d","cell_type":"markdown","source":"## Section 12 — Batch Tensor Inspection\n\nOne batch is extracted and its statistical properties are logged to confirm\nthat normalisation is centred near zero and one-hot encoding is correct.","metadata":{}},{"id":"40331ed4","cell_type":"code","source":"_inspect_ds = get_parquet_dataset(train_df.head(10), batch_size=2, augment=False)\nfor _bx, _by in _inspect_ds.take(1):\n    _x = _bx.numpy()\n    _y = _by.numpy()\n    logger.info(\"Feature tensor — shape: %s  min: %.4f  max: %.4f  mean: %.4f\",\n                _x.shape, _x.min(), _x.max(), _x.mean())\n    logger.info(\"Label tensor   — shape: %s  active class: %d  (%s)\",\n                _y.shape, _y[0].argmax(), label_to_sign.get(int(_y[0].argmax()), \"unknown\"))\ndel _inspect_ds\ngc.collect()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:36.032827Z","iopub.execute_input":"2026-04-07T04:44:36.03327Z","iopub.status.idle":"2026-04-07T04:44:36.442395Z","shell.execute_reply.started":"2026-04-07T04:44:36.033245Z","shell.execute_reply":"2026-04-07T04:44:36.44178Z"}},"outputs":[],"execution_count":null},{"id":"f301e23a","cell_type":"markdown","source":"## Section 13 — Stratified Train / Validation / Test Split\n\nThe dataset is split 80% / 10% / 10% with stratification on the class label\nto ensure consistent class frequencies across all three sets.  Splits are\nwritten to disk so that every model in the comparison trains and evaluates on\nexactly the same partitions.\n\n**This cell is part of the protected data pipeline and must not be modified.**","metadata":{}},{"id":"3928fccc","cell_type":"code","source":"logger.info(\"Performing stratified 80/10/10 split  seed=%d.\", CFG.SEED)\n\n_data_dir = os.path.join(DIRS[\"base\"], \"data\")\nos.makedirs(_data_dir, exist_ok=True)\n\ntrain_df_split, temp_df = train_test_split(\n    train_df, test_size=0.20, random_state=CFG.SEED, stratify=train_df[\"label\"]\n)\nval_df_split, test_df_split = train_test_split(\n    temp_df, test_size=0.50, random_state=CFG.SEED, stratify=temp_df[\"label\"]\n)\n\ntrain_df_split.to_csv(os.path.join(_data_dir, \"train_split.csv\"), index=False)\nval_df_split  .to_csv(os.path.join(_data_dir, \"val_split.csv\"),   index=False)\ntest_df_split .to_csv(os.path.join(_data_dir, \"test_split.csv\"),  index=False)\n\nlogger.info(\"Training   : %d samples  (%.1f%%)\", len(train_df_split), 100*len(train_df_split)/len(train_df))\nlogger.info(\"Validation : %d samples  (%.1f%%)\", len(val_df_split),   100*len(val_df_split)/len(train_df))\nlogger.info(\"Test       : %d samples  (%.1f%%)\", len(test_df_split),  100*len(test_df_split)/len(train_df))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:36.443185Z","iopub.execute_input":"2026-04-07T04:44:36.443383Z","iopub.status.idle":"2026-04-07T04:44:36.778459Z","shell.execute_reply.started":"2026-04-07T04:44:36.443362Z","shell.execute_reply":"2026-04-07T04:44:36.777431Z"}},"outputs":[],"execution_count":null},{"id":"81682038","cell_type":"markdown","source":"## Section 14 — Dataset Construction\n\nThree  pipelines are instantiated from the splits.\nAugmentation and shuffling are applied **only** to the training pipeline.\nSteps-per-epoch are derived from split sizes and batch size.","metadata":{}},{"id":"0a24c99b","cell_type":"code","source":"train_dataset = get_parquet_dataset(\n    train_df_split, data_dir=CFG.DATA_DIR, batch_size=CFG.BATCH_SIZE,\n    max_len=MAX_LEN, augment=True, shuffle=True,\n)\nval_dataset = get_parquet_dataset(\n    val_df_split, data_dir=CFG.DATA_DIR, batch_size=CFG.BATCH_SIZE,\n    max_len=MAX_LEN, augment=False, shuffle=False,\n)\ntest_dataset = get_parquet_dataset(\n    test_df_split, data_dir=CFG.DATA_DIR, batch_size=CFG.BATCH_SIZE,\n    max_len=MAX_LEN, augment=False, shuffle=False,\n)\n\nsteps_per_epoch  = len(train_df_split) // CFG.BATCH_SIZE\nvalidation_steps = len(val_df_split)   // CFG.BATCH_SIZE\ntest_steps       = len(test_df_split)  // CFG.BATCH_SIZE\n\nlogger.info(\"Steps/epoch: %d  |  Val steps: %d  |  Test steps: %d\",\n            steps_per_epoch, validation_steps, test_steps)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:36.779552Z","iopub.execute_input":"2026-04-07T04:44:36.779918Z","iopub.status.idle":"2026-04-07T04:44:37.36619Z","shell.execute_reply.started":"2026-04-07T04:44:36.779893Z","shell.execute_reply":"2026-04-07T04:44:37.365517Z"}},"outputs":[],"execution_count":null},{"id":"1c1091eb","cell_type":"markdown","source":"## Section 15 — Pre-Training Dataset Report\n\nA statistical summary of the split is saved to disk and visualised as\na pie chart (allocation) and bar chart (per-class sample count).","metadata":{}},{"id":"c6416d3c","cell_type":"code","source":"_tc = train_df_split[\"label\"].value_counts()\n_vc = val_df_split  [\"label\"].value_counts()\n_xc = test_df_split [\"label\"].value_counts()\n\n_report = (\n    f\"=========================================================\\n\"\n    f\"       DATASET REPORT — {CFG.MODEL_NAME}\\n\"\n    f\"=========================================================\\n\"\n    f\"Total sequences         : {len(train_df)}\\n\"\n    f\"Unique classes          : {NUM_CLASSES}\\n\"\n    f\"Selected landmark nodes : {NUM_NODES}\\n\"\n    f\"Feature channels        : {CHANNELS}\\n\"\n    f\"Max sequence length     : {MAX_LEN} frames\\n\"\n    f\"Input tensor shape      : (Batch, {MAX_LEN}, {CHANNELS})\\n\"\n    f\"---------------------------------------------------------\\n\"\n    f\"Training   : {len(train_df_split)} samples\\n\"\n    f\"Validation : {len(val_df_split)} samples\\n\"\n    f\"Test       : {len(test_df_split)} samples\\n\"\n    f\"---------------------------------------------------------\\n\"\n    f\"[Train] max={_tc.max()}  min={_tc.min()}  mean={_tc.mean():.1f}\\n\"\n    f\"[Val]   max={_vc.max()}  min={_vc.min()}  mean={_vc.mean():.1f}\\n\"\n    f\"[Test]  max={_xc.max()}  min={_xc.min()}  mean={_xc.mean():.1f}\\n\"\n    f\"=========================================================\\n\"\n)\nlogger.info(_report)\n\nwith open(os.path.join(DIRS[\"metrics\"], \"dataset_report.txt\"), \"w\") as _f:\n    _f.write(_report)\n\n_fig, (_ax1, _ax2) = plt.subplots(1, 2, figsize=(14, 6))\n_ax1.pie(\n    [len(train_df_split), len(val_df_split), len(test_df_split)],\n    labels=[\"Training (80%)\", \"Validation (10%)\", \"Test (10%)\"],\n    colors=[\"#4285F4\", \"#34A853\", \"#FBBC05\"],\n    autopct=\"%1.1f%%\", startangle=90, textprops={\"fontsize\": 12},\n)\n_ax1.set_title(\"Dataset Allocation\", fontsize=14, fontweight=\"bold\")\n\n_means = [_tc.mean(), _vc.mean(), _xc.mean()]\n_ax2.bar([\"Train\", \"Validation\", \"Test\"], _means,\n         color=[\"#4285F4\", \"#34A853\", \"#FBBC05\"], width=0.5)\n_ax2.set_ylabel(\"Mean Sequences per Class\")\n_ax2.set_title(\"Average Class Representation\", fontsize=14, fontweight=\"bold\")\nfor _i, _v in enumerate(_means):\n    _ax2.text(_i, _v + 1, f\"{_v:.1f}\", ha=\"center\", fontweight=\"bold\")\n\nplt.tight_layout()\nplt.savefig(os.path.join(DIRS[\"plots\"], \"data_split_distribution.png\"), dpi=300, bbox_inches=\"tight\")\nplt.show()\nlogger.info(\"Pre-training report saved.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:37.367016Z","iopub.execute_input":"2026-04-07T04:44:37.367335Z","iopub.status.idle":"2026-04-07T04:44:38.169329Z","shell.execute_reply.started":"2026-04-07T04:44:37.367297Z","shell.execute_reply":"2026-04-07T04:44:38.168699Z"}},"outputs":[],"execution_count":null},{"id":"539baee3","cell_type":"markdown","source":"\n## **Section 16 — Model Architecture: Enhanced BiGRU**\n\nThe enhanced Bidirectional GRU architecture builds on a strong sequence modeling baseline with additional mechanisms for better feature extraction and generalisation:\n\n* **Masking** — padded timesteps (sentinel value) are excluded from computation before entering the network\n\n* **Stem Projection**\n  A dense projection layer maps input features into a higher-level representation space (`dim`), followed by:\n\n  * Batch Normalization\n  * Swish activation\n  * SpatialDropout1D for temporal regularisation\n\n* **Stacked Bidirectional GRU Layers**\n  Two BiGRU blocks are used to capture both forward and backward temporal dependencies:\n\n  * First layer: higher dimensional representation\n  * Second layer: compressed representation\n  * Each block is followed by **Layer Normalization** for stable training\n\n* **Attention Mechanism**\n  A lightweight attention layer is applied over the temporal dimension to:\n\n  * Learn importance weights across timesteps\n  * Focus on the most informative parts of the sequence\n  * Produce a weighted representation via attention pooling\n\n* **Multi-View Temporal Aggregation**\n  Instead of relying on a single pooling strategy, the model combines:\n\n  * Attention-based representation\n  * Global Average Pooling (captures overall trends)\n  * Global Max Pooling (captures strong signals)\n\n  These representations are concatenated to form a richer feature vector.\n\n* **Classification Head**\n  The aggregated features pass through:\n\n  * Dense layer with Swish activation\n  * Batch Normalization\n  * Dropout for regularisation\n\n* **Output Layer (AMP-safe)**\n  Final dense layer outputs raw logits with `float32` precision to ensure numerical stability during mixed precision training.\n\n\n\n### **Model Flexibility**\n\nTo experiment with different architectures:\n\n* Define a new `get_model()` function\n* Update `CFG.MODEL_NAME` and model reference\n\nAll downstream training, logging, checkpointing, and evaluation pipelines will adapt automatically without any changes to the data processing pipeline.\n\n","metadata":{}},{"id":"5d68ad54","cell_type":"code","source":"class MaskableConv1D(layers.Conv1D):\n    \"\"\"Conv1D that propagates the padding mask to downstream layers.\"\"\"\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.supports_masking = True\n\n\nclass Squeeze(layers.Layer):\n    \"\"\"Removes a given axis. Replaces Lambda(tf.squeeze) with masking support.\"\"\"\n    def __init__(self, axis, **kwargs):\n        super().__init__(**kwargs)\n        self.axis = axis\n        self.supports_masking = True\n\n    def call(self, x):\n        return tf.squeeze(x, axis=self.axis)\n\n    def get_config(self):\n        return {**super().get_config(), \"axis\": self.axis}\n\n\nclass ExpandDims(layers.Layer):\n    \"\"\"Adds a given axis. Replaces Lambda(tf.expand_dims) with masking support.\"\"\"\n    def __init__(self, axis, **kwargs):\n        super().__init__(**kwargs)\n        self.axis = axis\n        self.supports_masking = True\n\n    def call(self, x):\n        return tf.expand_dims(x, axis=self.axis)\n\n    def get_config(self):\n        return {**super().get_config(), \"axis\": self.axis}\n\n\nclass ReduceSum(layers.Layer):\n    \"\"\"Reduces along a given axis. Replaces Lambda(tf.reduce_sum).\"\"\"\n    def __init__(self, axis, **kwargs):\n        super().__init__(**kwargs)\n        self.axis = axis\n        self.supports_masking = True\n\n    def call(self, x):\n        return tf.reduce_sum(x, axis=self.axis)\n\n    def get_config(self):\n        return {**super().get_config(), \"axis\": self.axis}\n\n\nclass MaskableGlobalMaxPool1D(layers.GlobalMaxPooling1D):\n    \"\"\"GlobalMaxPooling1D that propagates the padding mask.\"\"\"\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.supports_masking = True\n\n\ndef get_model(max_len, channels, num_classes, dim=256, dropout_rate=0.4):\n    inp = layers.Input(shape=(max_len, channels), name=\"input_features\")\n\n    # ── Masking ────────────────────────────────────────────────────────────────\n    x = layers.Masking(mask_value=-100.0, name=\"masking\")(inp)\n\n    # ── Conv1D Stem ────────────────────────────────────────────────────────────\n    # MaskableConv1D preserves the padding mask for all downstream layers.\n    x = MaskableConv1D(dim, kernel_size=3, padding=\"same\", use_bias=False, name=\"stem_conv\")(x)\n    x = layers.BatchNormalization(momentum=0.95, name=\"stem_bn\")(x)\n    x = layers.Activation(\"swish\", name=\"stem_act\")(x)\n    x = layers.SpatialDropout1D(dropout_rate, name=\"stem_drop\")(x)\n\n    # ── BiGRU Block 1 + Residual ───────────────────────────────────────────────\n    # skip1 projects x from dim to dim*2 to match the BiGRU output width.\n    skip1 = layers.Dense(dim * 2, use_bias=False, name=\"skip1_proj\")(x)\n    x = layers.Bidirectional(\n        layers.GRU(dim, return_sequences=True, dropout=0.25, recurrent_dropout=0.0),\n        name=\"bigru_1\",\n    )(x)\n    x = layers.LayerNormalization(epsilon=1e-6, name=\"ln_1\")(x)\n    x = layers.Add(name=\"res_1\")([x, skip1])\n\n    # ── BiGRU Block 2 + Residual ───────────────────────────────────────────────\n    # skip2 projects the dim*2 input down to dim to match the BiGRU output width.\n    skip2 = layers.Dense(dim, use_bias=False, name=\"skip2_proj\")(x)\n    x = layers.Bidirectional(\n        layers.GRU(dim // 2, return_sequences=True, dropout=0.25, recurrent_dropout=0.0),\n        name=\"bigru_2\",\n    )(x)\n    x = layers.LayerNormalization(epsilon=1e-6, name=\"ln_2\")(x)\n    x = layers.Add(name=\"res_2\")([x, skip2])\n\n    # ── Projected Attention ────────────────────────────────────────────────────\n    # attn_score shape: (B, T, 1).\n    # Softmax receives the padding mask with shape (B, T) — dimensions must match\n    # on the time axis, so we squeeze before Softmax and restore after.\n    # FIX: Lambda replaced with Squeeze / ExpandDims (support masking).\n    attn = layers.Dense(dim // 4, activation=\"tanh\", name=\"attn_proj\")(x)\n    attn = layers.Dense(1, name=\"attn_score\")(attn)                   # (B, T, 1)\n    attn = Squeeze(axis=-1, name=\"attn_squeeze\")(attn)                 # (B, T)\n    attn = layers.Softmax(axis=1, name=\"attn_weights\")(attn)           # (B, T)\n    attn = ExpandDims(axis=-1, name=\"attn_expand\")(attn)               # (B, T, 1)\n    x_attn = layers.Multiply(name=\"attn_apply\")([x, attn])             # (B, T, dim)\n    x_attn = ReduceSum(axis=1, name=\"attn_sum\")(x_attn)               # (B, dim)\n\n    # ── Pooling Combination ────────────────────────────────────────────────────\n    # FIX: MaskableGlobalMaxPool1D instead of GlobalMaxPooling1D.\n    gap = layers.GlobalAveragePooling1D(name=\"gap\")(x)\n    gmp = MaskableGlobalMaxPool1D(name=\"gmp\")(x)\n\n    # ── Pool Projection ────────────────────────────────────────────────────────\n    # Concat produces (B, 3*dim). The projection compresses back to (B, dim)\n    # before the head so the head Dense does not carry an unnecessarily large input.\n    fused = layers.Concatenate(name=\"concat\")([x_attn, gap, gmp])\n    fused = layers.Dense(dim, use_bias=False, name=\"pool_proj\")(fused)\n    fused = layers.LayerNormalization(epsilon=1e-6, name=\"pool_ln\")(fused)\n    fused = layers.Activation(\"swish\", name=\"pool_act\")(fused)\n\n    # ── Classification Head ────────────────────────────────────────────────────\n    x = layers.Dense(dim, activation=\"swish\", use_bias=False, name=\"head_dense\")(fused)\n    x = layers.BatchNormalization(momentum=0.95, name=\"head_bn\")(x)\n    x = layers.Dropout(dropout_rate + 0.1, name=\"head_drop\")(x)\n\n    # dtype=float32 ensures stable output under mixed precision (AMP).\n    outputs = layers.Dense(num_classes, dtype=\"float32\", name=\"classifier\")(x)\n\n    return Model(inputs=inp, outputs=outputs, name=CFG.MODEL_NAME)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:38.170601Z","iopub.execute_input":"2026-04-07T04:44:38.170952Z","iopub.status.idle":"2026-04-07T04:44:38.18969Z","shell.execute_reply.started":"2026-04-07T04:44:38.170926Z","shell.execute_reply":"2026-04-07T04:44:38.188919Z"}},"outputs":[],"execution_count":null},{"id":"fe385e69","cell_type":"markdown","source":"## Section 17 — Model Initialisation and Architecture Logging\n\nThe model is instantiated from the architecture function. Its parameter count\nand layer summary are persisted to the metrics directory. An architecture\ndiagram is generated when  and  are available.","metadata":{}},{"id":"e3e11d9b","cell_type":"code","source":"model = get_model(max_len=MAX_LEN, channels=CHANNELS, num_classes=NUM_CLASSES)\nmodel.summary(print_fn=logger.info)\n\n_params    = model.count_params()\n_trainable = sum(tf.keras.backend.count_params(w) for w in model.trainable_weights)\nwith open(os.path.join(DIRS[\"metrics\"], \"model_params.txt\"), \"w\") as _f:\n    _f.write(f\"Total parameters    : {_params:,}\\n\")\n    _f.write(f\"Trainable parameters: {_trainable:,}\\n\")\n\nlogger.info(\"Parameters — total: %s  trainable: %s\", f\"{_params:,}\", f\"{_trainable:,}\")\n\ntry:\n    _diag_path = os.path.join(DIRS[\"plots\"], f\"{CFG.MODEL_NAME}_architecture.png\")\n    plot_model(model, to_file=_diag_path, show_shapes=True, show_layer_names=True, dpi=150)\n    logger.info(\"Architecture diagram saved: %s\", _diag_path)\nexcept Exception as _e:\n    logger.warning(\"Architecture diagram skipped (pydot/graphviz required): %s\", _e)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:38.190717Z","iopub.execute_input":"2026-04-07T04:44:38.19119Z","iopub.status.idle":"2026-04-07T04:44:40.892833Z","shell.execute_reply.started":"2026-04-07T04:44:38.191134Z","shell.execute_reply":"2026-04-07T04:44:40.892201Z"}},"outputs":[],"execution_count":null},{"id":"38ec73ad","cell_type":"markdown","source":"## Section 18 — Learning Rate Schedule and Optimiser\n\nA cosine-decay schedule with linear warm-up is implemented as a\n subclass.  Because the schedule is a function of the\n**global optimiser step** (not the epoch counter), it resumes seamlessly from\na checkpoint — the learning rate and momentum buffers are both restored.\n\n wraps the base Adam optimiser when AMP is enabled to\nprevent FP16 underflow during backpropagation.","metadata":{}},{"id":"43004fdd","cell_type":"code","source":"# ── Section 18 — Learning Rate Schedule and Optimiser ────────────────────────\n# WarmupCosineDecay is defined here as a LearningRateSchedule subclass so it\n# is keyed to the global optimizer step, not the epoch counter. This means the\n# schedule continues correctly after a checkpoint restore with no offset fix.\n# ─────────────────────────────────────────────────────────────────────────────\n\nclass WarmupCosineDecay(tf.keras.optimizers.schedules.LearningRateSchedule):\n    def __init__(self, peak_lr, total_steps, warmup_steps, min_lr=CFG.MIN_LR):\n        super().__init__()\n        self.peak_lr      = float(peak_lr)\n        self.total_steps  = float(total_steps)\n        self.warmup_steps = float(warmup_steps)\n        self.min_lr       = float(min_lr)\n\n    def __call__(self, step):\n        step      = tf.cast(step, tf.float32)\n        warmup    = self.peak_lr * step / tf.maximum(self.warmup_steps, 1.0)\n        cos_input = (step - self.warmup_steps) / tf.maximum(\n            self.total_steps - self.warmup_steps, 1.0\n        ) * math.pi\n        cos_decay = self.min_lr + 0.5 * (self.peak_lr - self.min_lr) * (\n            1.0 + tf.math.cos(cos_input)\n        )\n        return tf.where(step < self.warmup_steps, warmup, cos_decay)\n\n    def get_config(self):\n        return {\n            \"peak_lr\":      self.peak_lr,\n            \"total_steps\":  self.total_steps,\n            \"warmup_steps\": self.warmup_steps,\n            \"min_lr\":       self.min_lr,\n        }\n\n\n_total_steps  = steps_per_epoch * CFG.EPOCHS\n_warmup_steps = int(_total_steps * CFG.WARMUP_RATIO)\n\nlr_schedule = WarmupCosineDecay(CFG.LEARNING_RATE, _total_steps, _warmup_steps, CFG.MIN_LR)\noptimizer   = optimizers.Adam(learning_rate=lr_schedule)\nloss_fn     = losses.CategoricalCrossentropy(from_logits=True, label_smoothing=CFG.LABEL_SMOOTHING)\n\nlogger.info(\"Optimiser : Adam + WarmupCosineDecay\")\nlogger.info(\"Peak LR   : %.2e  |  Min LR: %.2e  |  Warm-up steps: %d\",\n            CFG.LEARNING_RATE, CFG.MIN_LR, _warmup_steps)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:40.89388Z","iopub.execute_input":"2026-04-07T04:44:40.894477Z","iopub.status.idle":"2026-04-07T04:44:40.9078Z","shell.execute_reply.started":"2026-04-07T04:44:40.894448Z","shell.execute_reply":"2026-04-07T04:44:40.907096Z"}},"outputs":[],"execution_count":null},{"id":"f0f2f206","cell_type":"markdown","source":"## Section 19 — Checkpointing and Resume Logic\n\n saves model weights and the full optimiser state\n(including the LR schedule step counter) atomically.  On restart:\n\n1. The pipeline scans  for an existing checkpoint.\n2. If found, model weights, optimiser state, and epoch counter are restored.\n3. Training resumes from the correct epoch with the LR exactly as left.\n\nTwo checkpoints are maintained:\n\n| Directory | Trigger |\n|---|---|\n|  | Saved at the end of every epoch |\n|  | Saved when validation accuracy improves |","metadata":{}},{"id":"430e74e3-8514-4a5a-9d1b-1888291f54a5","cell_type":"code","source":"# ── Section 19 — Checkpointing and Resume Logic ───────────────────────────────\n# tf.train.Checkpoint saves model weights and the full optimizer state\n# (including the LR schedule step counter) atomically. On restart:\n#   1. Scans checkpoints/last/ for an existing checkpoint.\n#   2. If found AND the files physically exist, restores everything.\n#   3. If the checkpoint index exists but the files are missing (e.g. after\n#      a Kaggle session reset), clears the stale index and starts fresh.\n#   4. If CFG.RESET_CHECKPOINT=True, wipes everything before starting.\n# ─────────────────────────────────────────────────────────────────────────────\n\nimport shutil\n\n_ckpt_dir_last = os.path.join(DIRS[\"checkpoints\"], \"last\")\n_ckpt_dir_best = os.path.join(DIRS[\"checkpoints\"], \"best\")\nos.makedirs(_ckpt_dir_last, exist_ok=True)\nos.makedirs(_ckpt_dir_best, exist_ok=True)\n\nepoch_var    = tf.Variable(0,   trainable=False, dtype=tf.int64,   name=\"epoch\")\nbest_val_acc = tf.Variable(0.0, trainable=False, dtype=tf.float32, name=\"best_val_acc\")\n\ncheckpoint   = tf.train.Checkpoint(\n    model=model,\n    optimizer=optimizer,\n    epoch=epoch_var,\n    best_val_acc=best_val_acc,\n)\nmanager_last = tf.train.CheckpointManager(checkpoint, _ckpt_dir_last, max_to_keep=2)\nmanager_best = tf.train.CheckpointManager(checkpoint, _ckpt_dir_best, max_to_keep=1)\n\n\ndef _checkpoint_files_exist(manager):\n    \"\"\"Returns True only if the checkpoint index file physically exists on disk.\"\"\"\n    latest = manager.latest_checkpoint\n    if latest is None:\n        return False\n    return os.path.exists(latest + \".index\")\n\n\n# Wipe stale or incompatible checkpoints when requested via config.\nif CFG.RESET_CHECKPOINT:\n    shutil.rmtree(_ckpt_dir_last, ignore_errors=True)\n    shutil.rmtree(_ckpt_dir_best, ignore_errors=True)\n    os.makedirs(_ckpt_dir_last, exist_ok=True)\n    os.makedirs(_ckpt_dir_best, exist_ok=True)\n    # Re-create managers after wiping so their internal state is clean.\n    manager_last = tf.train.CheckpointManager(checkpoint, _ckpt_dir_last, max_to_keep=2)\n    manager_best = tf.train.CheckpointManager(checkpoint, _ckpt_dir_best, max_to_keep=1)\n    logger.info(\"RESET_CHECKPOINT=True — all checkpoints cleared.\")\n\ninitial_epoch = 0\n\nif not _checkpoint_files_exist(manager_last):\n    # Covers three cases:\n    #   a) No checkpoint was ever saved (fresh run).\n    #   b) Kaggle session reset wiped /kaggle/working but the manager\n    #      still holds a stale path in its checkpoint file.\n    #   c) CFG.RESET_CHECKPOINT just cleared everything above.\n    if manager_last.latest_checkpoint is not None:\n        # Stale index — clear it so future saves start from ckpt-1.\n        shutil.rmtree(_ckpt_dir_last, ignore_errors=True)\n        shutil.rmtree(_ckpt_dir_best, ignore_errors=True)\n        os.makedirs(_ckpt_dir_last, exist_ok=True)\n        os.makedirs(_ckpt_dir_best, exist_ok=True)\n        manager_last = tf.train.CheckpointManager(checkpoint, _ckpt_dir_last, max_to_keep=2)\n        manager_best = tf.train.CheckpointManager(checkpoint, _ckpt_dir_best, max_to_keep=1)\n        logger.warning(\"Stale checkpoint index found but files are missing — cleared and starting fresh.\")\n    else:\n        logger.info(\"No checkpoint found — starting fresh training.\")\nelse:\n    try:\n        checkpoint.restore(manager_last.latest_checkpoint)\n        initial_epoch = int(epoch_var.numpy())\n        logger.info(\"Checkpoint restored: %s — resuming from epoch %d.\",\n                    manager_last.latest_checkpoint, initial_epoch + 1)\n    except Exception as _e:\n        logger.error(\"Checkpoint restore failed (%s) — starting fresh.\", _e)\n        shutil.rmtree(_ckpt_dir_last, ignore_errors=True)\n        shutil.rmtree(_ckpt_dir_best, ignore_errors=True)\n        os.makedirs(_ckpt_dir_last, exist_ok=True)\n        os.makedirs(_ckpt_dir_best, exist_ok=True)\n        manager_last = tf.train.CheckpointManager(checkpoint, _ckpt_dir_last, max_to_keep=2)\n        manager_best = tf.train.CheckpointManager(checkpoint, _ckpt_dir_best, max_to_keep=1)\n        initial_epoch = 0","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:40.9088Z","iopub.execute_input":"2026-04-07T04:44:40.909123Z","iopub.status.idle":"2026-04-07T04:44:40.977375Z","shell.execute_reply.started":"2026-04-07T04:44:40.909076Z","shell.execute_reply":"2026-04-07T04:44:40.976813Z"}},"outputs":[],"execution_count":null},{"id":"88a567c4","cell_type":"markdown","source":"## Section 20 — Stateful Metric Objects\n\nStateful Keras metric objects accumulate values over a full epoch and are\nreset at the start of each epoch. This is correct even when gradient\naccumulation splits one nominal epoch into multiple micro-steps.","metadata":{}},{"id":"2aef8e2b","cell_type":"code","source":"train_loss_m  = metrics.Mean(name=\"loss\")\ntrain_acc_m   = metrics.CategoricalAccuracy(name=\"accuracy\")\ntrain_top5_m  = metrics.TopKCategoricalAccuracy(k=5, name=\"top_5_accuracy\")\n\nval_loss_m    = metrics.Mean(name=\"val_loss\")\nval_acc_m     = metrics.CategoricalAccuracy(name=\"val_accuracy\")\nval_top5_m    = metrics.TopKCategoricalAccuracy(k=5, name=\"val_top_5_accuracy\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:40.978316Z","iopub.execute_input":"2026-04-07T04:44:40.978744Z","iopub.status.idle":"2026-04-07T04:44:41.000101Z","shell.execute_reply.started":"2026-04-07T04:44:40.978683Z","shell.execute_reply":"2026-04-07T04:44:40.999566Z"}},"outputs":[],"execution_count":null},{"id":"eeef29cc","cell_type":"markdown","source":"## Section 21 — Custom Training and Validation Steps\n\n implements one forward pass with optional gradient accumulation\nand one optimiser update.  When , gradients are\nsummed across micro-batches before weights are updated, simulating a larger\neffective batch size.\n\nThe AMP path uses  /  to prevent\nFP16 underflow during backpropagation.  Gradients are clipped by global norm\nbefore the optimiser step to ensure training stability.","metadata":{}},{"id":"35300507","cell_type":"code","source":"@tf.function\ndef train_step(x_batch, y_batch, accum_grads, step_in_accum):\n    with tf.GradientTape() as tape:\n        y_pred   = model(x_batch, training=True)\n        raw_loss = loss_fn(y_batch, y_pred)\n        # Divide by accumulation steps so the effective loss magnitude is consistent\n        # regardless of how many micro-batches are accumulated before the update.\n        scaled   = raw_loss / tf.cast(CFG.GRAD_ACCUM_STEPS, raw_loss.dtype)\n\n    grads     = tape.gradient(scaled, model.trainable_variables)\n    new_accum = [ag + g for ag, g in zip(accum_grads, grads)]\n\n    if tf.equal(step_in_accum, CFG.GRAD_ACCUM_STEPS - 1):\n        clipped, _ = tf.clip_by_global_norm(new_accum, 1.0)\n        optimizer.apply_gradients(zip(clipped, model.trainable_variables))\n        new_accum  = [tf.zeros_like(v) for v in model.trainable_variables]\n\n    train_loss_m.update_state(raw_loss)\n    train_acc_m .update_state(y_batch, y_pred)\n    train_top5_m.update_state(y_batch, y_pred)\n    return new_accum\n\n\n@tf.function\ndef val_step(x_batch, y_batch):\n    y_pred = model(x_batch, training=False)\n    val_loss_m.update_state(loss_fn(y_batch, y_pred))\n    val_acc_m .update_state(y_batch, y_pred)\n    val_top5_m.update_state(y_batch, y_pred)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:41.000905Z","iopub.execute_input":"2026-04-07T04:44:41.001216Z","iopub.status.idle":"2026-04-07T04:44:41.00768Z","shell.execute_reply.started":"2026-04-07T04:44:41.001177Z","shell.execute_reply":"2026-04-07T04:44:41.00698Z"}},"outputs":[],"execution_count":null},{"id":"49c14b3d","cell_type":"markdown","source":"## Section 22 — Training Loop with Fail-Safe Execution\n\nThe main loop:\n\n1. Resets all metric objects at the start of each epoch.\n2. Runs training micro-batches with gradient accumulation.\n3. Runs all validation batches.\n4. Logs per-epoch metrics and appends them to a CSV history file.\n5. Saves the last-epoch checkpoint unconditionally.\n6. Saves the best-model checkpoint when validation accuracy improves.\n7. Triggers early stopping after  epochs without improvement.\n\nThe loop is wrapped in a  block. On any exception the last\ncheckpoint is saved before the exception propagates so no progress is lost.","metadata":{}},{"id":"78475e31","cell_type":"code","source":"def run_training():\n    history_rows   = []\n    early_stop_ctr = 0\n    history_csv    = os.path.join(DIRS[\"metrics\"], f\"{CFG.MODEL_NAME}_training_history.csv\")\n\n    if os.path.exists(history_csv):\n        history_rows = pd.read_csv(history_csv).to_dict(\"records\")\n        logger.info(\"Loaded %d existing history rows from CSV.\", len(history_rows))\n\n    accum_grads = [tf.zeros_like(v) for v in model.trainable_variables]\n\n    for epoch in range(initial_epoch, CFG.EPOCHS):\n        t0 = time.time()\n\n        for m in [train_loss_m, train_acc_m, train_top5_m,\n                  val_loss_m,   val_acc_m,   val_top5_m]:\n            m.reset_state()\n\n        # Training phase\n        for step, (xb, yb) in enumerate(train_dataset.take(steps_per_epoch)):\n            accum_grads = train_step(xb, yb, accum_grads, step % CFG.GRAD_ACCUM_STEPS)\n\n        # Validation phase\n        for xb, yb in val_dataset.take(validation_steps):\n            val_step(xb, yb)\n\n        t_loss = float(train_loss_m.result());  t_acc = float(train_acc_m.result());  t_top5 = float(train_top5_m.result())\n        v_loss = float(val_loss_m  .result());  v_acc = float(val_acc_m  .result());  v_top5 = float(val_top5_m  .result())\n        elapsed = time.time() - t0\n\n        logger.info(\n            \"Epoch %03d/%03d | loss=%.4f acc=%.4f top5=%.4f | \"            \"val_loss=%.4f val_acc=%.4f val_top5=%.4f | %.1fs\",\n            epoch + 1, CFG.EPOCHS, t_loss, t_acc, t_top5, v_loss, v_acc, v_top5, elapsed,\n        )\n\n        row = {\"epoch\": epoch + 1,\n               \"loss\": t_loss, \"accuracy\": t_acc, \"top_5_accuracy\": t_top5,\n               \"val_loss\": v_loss, \"val_accuracy\": v_acc, \"val_top_5_accuracy\": v_top5,\n               \"elapsed_s\": elapsed}\n        history_rows.append(row)\n        pd.DataFrame(history_rows).to_csv(history_csv, index=False)\n\n        epoch_var.assign(epoch + 1)\n        manager_last.save()\n\n        if v_acc > float(best_val_acc.numpy()):\n            best_val_acc.assign(v_acc)\n            manager_best.save()\n            logger.info(\"  Best model saved — val_accuracy=%.4f\", v_acc)\n            early_stop_ctr = 0\n        else:\n            early_stop_ctr += 1\n            logger.info(\"  No improvement for %d/%d epochs.\", early_stop_ctr, CFG.EARLY_STOP_PAT)\n            if early_stop_ctr >= CFG.EARLY_STOP_PAT:\n                logger.info(\"Early stopping triggered at epoch %d.\", epoch + 1)\n                break\n\n    return pd.DataFrame(history_rows)\n\n\ntry:\n    history_df = run_training()\n    logger.info(\"Training completed successfully.\")\nexcept KeyboardInterrupt:\n    logger.warning(\"Interrupted by user — saving emergency checkpoint.\")\n    manager_last.save()\n    history_df = None\nexcept Exception as _exc:\n    logger.error(\"Training failed: %s\", _exc)\n    logger.error(traceback.format_exc())\n    try:\n        manager_last.save()\n        logger.info(\"Emergency checkpoint saved.\")\n    except Exception as _e2:\n        logger.error(\"Emergency checkpoint also failed: %s\", _e2)\n    raise\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:41.008649Z","iopub.execute_input":"2026-04-07T04:44:41.008924Z","iopub.status.idle":"2026-04-07T04:44:58.178747Z","shell.execute_reply.started":"2026-04-07T04:44:41.008901Z","shell.execute_reply":"2026-04-07T04:44:58.177662Z"}},"outputs":[],"execution_count":null},{"id":"af74c2f1","cell_type":"markdown","source":"## Section 23 — Training History Visualisation\n\nLoss and accuracy curves are plotted from the CSV history file.\nUsing the CSV (rather than the in-memory object) ensures plots are correct\neven when training was resumed across multiple Kaggle sessions.","metadata":{}},{"id":"0ac4c8a8","cell_type":"code","source":"_history_csv = os.path.join(DIRS[\"metrics\"], f\"{CFG.MODEL_NAME}_training_history.csv\")\n\nif os.path.exists(_history_csv):\n    _hist = pd.read_csv(_history_csv)\n\n    _fig, _axes = plt.subplots(1, 2, figsize=(16, 5))\n    _fig.suptitle(f\"{CFG.MODEL_NAME} — Training History\", fontsize=14, fontweight=\"bold\")\n\n    _axes[0].plot(_hist[\"epoch\"], _hist[\"accuracy\"],     label=\"Train\",      color=\"#4285F4\", lw=2)\n    _axes[0].plot(_hist[\"epoch\"], _hist[\"val_accuracy\"], label=\"Validation\", color=\"#34A853\", lw=2)\n    _axes[0].set_title(\"Accuracy\"); _axes[0].set_xlabel(\"Epoch\"); _axes[0].set_ylabel(\"Accuracy\")\n    _axes[0].legend(); _axes[0].grid(True, ls=\"--\", alpha=0.6)\n\n    _axes[1].plot(_hist[\"epoch\"], _hist[\"loss\"],     label=\"Train\",      color=\"#EA4335\", lw=2)\n    _axes[1].plot(_hist[\"epoch\"], _hist[\"val_loss\"], label=\"Validation\", color=\"#FBBC05\", lw=2)\n    _axes[1].set_title(\"Loss\"); _axes[1].set_xlabel(\"Epoch\"); _axes[1].set_ylabel(\"Loss\")\n    _axes[1].legend(); _axes[1].grid(True, ls=\"--\", alpha=0.6)\n\n    plt.tight_layout()\n    _plot_path = os.path.join(DIRS[\"plots\"], f\"{CFG.MODEL_NAME}_training_history.png\")\n    plt.savefig(_plot_path, dpi=300, bbox_inches=\"tight\")\n    plt.show()\n    logger.info(\"Training history plot saved: %s\", _plot_path)\nelse:\n    logger.warning(\"History CSV not found — skipping plot.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:58.179936Z","iopub.execute_input":"2026-04-07T04:44:58.180388Z","iopub.status.idle":"2026-04-07T04:44:59.147207Z","shell.execute_reply.started":"2026-04-07T04:44:58.180353Z","shell.execute_reply":"2026-04-07T04:44:59.14659Z"}},"outputs":[],"execution_count":null},{"id":"a648384e","cell_type":"markdown","source":"## Section 24 — Final Model Evaluation on the Test Set\n\nThe **best** checkpoint is restored before evaluation so that the reported\nmetrics correspond to the highest-performing model state, not the final\ntraining state.  Results are saved to the metrics directory.","metadata":{}},{"id":"b913344a","cell_type":"code","source":"if manager_best.latest_checkpoint:\n    checkpoint.restore(manager_best.latest_checkpoint)\n    logger.info(\"Best checkpoint restored: %s\", manager_best.latest_checkpoint)\n\ntest_loss_m  = metrics.Mean(name=\"test_loss\")\ntest_acc_m   = metrics.CategoricalAccuracy(name=\"test_accuracy\")\ntest_top5_m  = metrics.TopKCategoricalAccuracy(k=5, name=\"test_top5\")\n\nfor _xb, _yb in test_dataset.take(test_steps):\n    _yp = model(_xb, training=False)\n    test_loss_m.update_state(loss_fn(_yb, _yp))\n    test_acc_m .update_state(_yb, _yp)\n    test_top5_m.update_state(_yb, _yp)\n\ntest_results = {\n    \"model\":         CFG.MODEL_NAME,\n    \"test_loss\":     float(test_loss_m.result()),\n    \"test_accuracy\": float(test_acc_m .result()),\n    \"test_top5\":     float(test_top5_m.result()),\n}\n\nlogger.info(\"=\" * 57)\nlogger.info(\"  FINAL TEST RESULTS — %s\", CFG.MODEL_NAME)\nlogger.info(\"  Loss     : %.4f\", test_results[\"test_loss\"])\nlogger.info(\"  Accuracy : %.4f  (%.2f%%)\", test_results[\"test_accuracy\"], test_results[\"test_accuracy\"]*100)\nlogger.info(\"  Top-5    : %.4f  (%.2f%%)\", test_results[\"test_top5\"],     test_results[\"test_top5\"]*100)\nlogger.info(\"=\" * 57)\n\npd.DataFrame([test_results]).to_csv(\n    os.path.join(DIRS[\"metrics\"], f\"{CFG.MODEL_NAME}_test_results.csv\"), index=False\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-07T04:44:59.148074Z","iopub.execute_input":"2026-04-07T04:44:59.148294Z","execution_failed":"2026-04-07T04:45:04.844Z"}},"outputs":[],"execution_count":null},{"id":"54103d16","cell_type":"markdown","source":"## Section 25 — Save Per-Sample Predictions\n\nPredictions for every test sample are stored in a structured CSV with true\nlabel, predicted label, top-5 class indices, and per-class probabilities.\nThe format is identical across all models to enable direct ensemble comparison.","metadata":{}},{"id":"4d0cc47f","cell_type":"code","source":"_all_preds = []\nfor _xb, _yb in test_dataset.take(test_steps):\n    _logits = model(_xb, training=False)\n    _probs  = tf.nn.softmax(_logits).numpy()\n    _labels = tf.argmax(_yb, axis=-1).numpy()\n    _pred   = np.argmax(_probs, axis=-1)\n    for _i in range(len(_labels)):\n        _top5_idx  = np.argsort(_probs[_i])[::-1][:5].tolist()\n        _top5_prob = [round(float(_probs[_i][j]), 6) for j in _top5_idx]\n        _all_preds.append({\n            \"true_label\": int(_labels[_i]),\n            \"pred_label\": int(_pred[_i]),\n            \"correct\":    int(_labels[_i] == _pred[_i]),\n            \"top5_labels\": json.dumps(_top5_idx),\n            \"top5_probs\":  json.dumps(_top5_prob),\n            \"confidence\":  round(float(_probs[_i][_pred[_i]]), 6),\n        })\n\n_pred_df = pd.DataFrame(_all_preds)\n_pred_path = os.path.join(DIRS[\"predictions\"], f\"{CFG.MODEL_NAME}_test_predictions.csv\")\n_pred_df.to_csv(_pred_path, index=False)\nlogger.info(\"Per-sample predictions saved: %s  (%d rows)  accuracy=%.4f\",\n            _pred_path, len(_pred_df), _pred_df[\"correct\"].mean())\n","metadata":{"trusted":true,"execution":{"execution_failed":"2026-04-07T04:45:04.844Z"}},"outputs":[],"execution_count":null},{"id":"b172ed8f","cell_type":"markdown","source":"## Section 26 — Global Model Comparison Summary\n\nAfter every model run the global comparison CSV at  is\nupdated. The file aggregates results from all experiments, sorted by test\naccuracy, and is always current after each run.","metadata":{}},{"id":"45d30511","cell_type":"code","source":"_summary_path = CFG.COMPARISON_CSV\n\nif os.path.exists(_summary_path):\n    _summary_df = pd.read_csv(_summary_path)\n    _summary_df = _summary_df[_summary_df[\"model\"] != CFG.MODEL_NAME]\nelse:\n    _summary_df = pd.DataFrame()\n\n_new_row = pd.DataFrame([{\n    \"model\":         test_results[\"model\"],\n    \"test_loss\":     round(test_results[\"test_loss\"],     4),\n    \"test_accuracy\": round(test_results[\"test_accuracy\"], 4),\n    \"test_top5\":     round(test_results[\"test_top5\"],     4),\n    \"timestamp\":     datetime.datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"),\n}])\n\n_summary_df = pd.concat([_summary_df, _new_row], ignore_index=True)\n_summary_df = _summary_df.sort_values(\"test_accuracy\", ascending=False)\n_summary_df.to_csv(_summary_path, index=False)\n\nlogger.info(\"Global comparison summary updated: %s\", _summary_path)\ndisplay(_summary_df)\n","metadata":{"trusted":true,"execution":{"execution_failed":"2026-04-07T04:45:04.845Z"}},"outputs":[],"execution_count":null}]}