{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","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":129615,"databundleVersionId":15811332}],"dockerImageVersionId":31286,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\nos.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'\nfrom itertools import islice\nimport random\nimport numpy as np\nimport cv2\nimport tensorflow as tf\ntf.get_logger().setLevel('ERROR')\nfrom tensorflow.keras import layers, models, Input, backend as K, callbacks\nfrom tensorflow.keras.layers import (\n    Conv2D, MaxPool2D, Flatten, Dense, Dropout, GlobalAveragePooling2D,\n    BatchNormalization, ReLU, Lambda, Concatenate\n)\nfrom tensorflow.keras.optimizers import Adam, AdamW\nfrom tensorflow.keras.regularizers import l2\nfrom sklearn.model_selection import train_test_split\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.gridspec as gridspec\nfrom sklearn.metrics import (\n    accuracy_score, precision_recall_fscore_support,\n    confusion_matrix, classification_report\n)","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:01:39.461999Z","iopub.execute_input":"2026-03-06T03:01:39.462241Z","iopub.status.idle":"2026-03-06T03:02:03.356550Z","shell.execute_reply.started":"2026-03-06T03:01:39.462220Z","shell.execute_reply":"2026-03-06T03:02:03.355903Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ───────────────────────────────────────────────────────────────────────────────\n# CONFIGURATION\n# ───────────────────────────────────────────────────────────────────────────────\n############## Chnage Based on your data directory ###############################\nTRAIN_DIR     = r\"/kaggle/input/competitions/cvpr-mslr-2026-track-2/train\"\nVAL_DIR       = r\"/kaggle/input/competitions/cvpr-mslr-2026-track-2/val\"\n##################################################################################\nNUM_CLASSES  = 126\nVALID_SPLIT  = 0.2\nRANDOM_SEED  = 42\n\nMIN_HEIGHT   = 12\nIMG_SIZE     = (256, 32)            # image dimensions fixed\nINPUT_SHAPE  = (*IMG_SIZE, 1)\n\nBATCH_SIZE   = 32\nEPOCHS       = 100\nLR           = 1e-3\nWD           = 1e-4\n\n# Directory to save checkpoints every 5 epochs\nCHECKPOINT_DIR = r\"/kaggle/working/checkpoints_md_cnn\"\nos.makedirs(CHECKPOINT_DIR, exist_ok=True)\n\nRESULTS_DIR = r\"/kaggle/working/nh_results\"\nos.makedirs(RESULTS_DIR, exist_ok=True)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:02:03.357323Z","iopub.execute_input":"2026-03-06T03:02:03.357752Z","iopub.status.idle":"2026-03-06T03:02:03.363037Z","shell.execute_reply.started":"2026-03-06T03:02:03.357715Z","shell.execute_reply":"2026-03-06T03:02:03.362415Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"random.seed(RANDOM_SEED)\nnp.random.seed(RANDOM_SEED)\ntf.random.set_seed(RANDOM_SEED)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:02:03.365223Z","iopub.execute_input":"2026-03-06T03:02:03.365651Z","iopub.status.idle":"2026-03-06T03:02:03.391462Z","shell.execute_reply.started":"2026-03-06T03:02:03.365627Z","shell.execute_reply":"2026-03-06T03:02:03.390864Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ───────────────────────────────────────────────────────────────────────────────\n# 1. COLLECT & SPLIT SAMPLES\n# ───────────────────────────────────────────────────────────────────────────────\n\ndef collect_all_samples(base_dir: str, num_classes: int):\n    samples = []\n    for cls_folder in sorted(os.listdir(base_dir)):\n        cls_path = os.path.join(base_dir, cls_folder)\n        if not os.path.isdir(cls_path):\n            continue\n        parts = cls_folder.split(\"_\")\n        try:\n            class_idx = int(parts[0])\n        except:\n            continue\n        if class_idx >= num_classes:\n            continue\n\n        for sample_folder in sorted(os.listdir(cls_path)):\n            if not sample_folder.startswith(\"SAMPLE_\"):\n                continue\n            sample_path = os.path.join(cls_path, sample_folder)\n            if not os.path.isdir(sample_path):\n                continue\n            npy_files = [f for f in os.listdir(sample_path) if f.lower().endswith(\".npy\")]\n            if len(npy_files) < 3:\n                continue\n            sample_num = sample_folder.split(\"_\")[1]\n            id_string = f\"{class_idx}_{sample_num}\"\n            samples.append((class_idx, sample_path, id_string))\n    return samples","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:02:03.392262Z","iopub.execute_input":"2026-03-06T03:02:03.392482Z","iopub.status.idle":"2026-03-06T03:02:03.401666Z","shell.execute_reply.started":"2026-03-06T03:02:03.392452Z","shell.execute_reply":"2026-03-06T03:02:03.401086Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_samples = collect_all_samples(TRAIN_DIR, NUM_CLASSES)\ntrain_labels = [s[0] for s in train_samples]\ntrain_samples, val_samples = train_test_split(\n    train_samples,\n    test_size=VALID_SPLIT,\n    stratify=train_labels,\n    random_state=RANDOM_SEED\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:02:03.402504Z","iopub.execute_input":"2026-03-06T03:02:03.402692Z","iopub.status.idle":"2026-03-06T03:03:28.611569Z","shell.execute_reply.started":"2026-03-06T03:02:03.402674Z","shell.execute_reply":"2026-03-06T03:03:28.610966Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def preprocess(sample_path: str, is_train: bool=True):\n    npy_files = sorted([f for f in os.listdir(sample_path)\n                        if f.lower().endswith(\".npy\")])[:3]\n    arrays = []\n    for f in npy_files:\n        arr = np.load(os.path.join(sample_path, f),\n                      allow_pickle=True).astype(np.float32)\n        if arr.ndim != 2:\n            pass\n        h, w = arr.shape\n        if h == IMG_SIZE[0]:\n            pass\n        elif w == IMG_SIZE[0]:\n            arr = arr.T\n        else:\n            arr = cv2.resize(arr, (w, IMG_SIZE[0]),\n                             interpolation=cv2.INTER_CUBIC)\n        if is_train and arr.shape[1] < MIN_HEIGHT:\n            return None\n        arrays.append(arr)\n    stacked = np.stack(arrays)          # (3, 256, time)\n    rmin, rmax = stacked.min(), stacked.max()\n    if rmax > rmin:\n        stacked = (stacked - rmin) / (rmax - rmin)\n    else:\n        stacked = np.zeros_like(stacked)\n    stacked = stacked[..., np.newaxis]  # (3, 256, ?, 1)\n    return stacked[0], stacked[1], stacked[2]\n\ndef build_md_arrays(sample_list, is_train: bool=True):\n    x0_list, x1_list, x2_list, y_list, ids = [], [], [], [], []\n    dropped = 0\n    for class_idx, sample_path, id_str in sample_list:\n        out = preprocess(sample_path, is_train=is_train)\n        if out is None:\n            dropped += 1\n            continue\n        md0, md1, md2 = out\n        x0_list.append(md0)\n        x1_list.append(md1)\n        x2_list.append(md2)\n        onehot = np.zeros(NUM_CLASSES, dtype=\"float32\")\n        onehot[class_idx] = 1.0\n        y_list.append(onehot)\n        ids.append(id_str)\n    y_arr = np.stack(y_list)\n    print(f\"x0: {len(x0_list)} samples, each shape {x0_list[0].shape}\")\n    print(f\"x1: {len(x1_list)} samples, each shape {x1_list[0].shape}\")\n    print(f\"x2: {len(x2_list)} samples, each shape {x2_list[0].shape}\")\n    print(f\"y:  {y_arr.shape}\")\n    print(f\"Filtered {dropped} samples (MIN_HEIGHT check)\")\n    return x0_list, x1_list, x2_list, y_arr, ids","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:03:28.612449Z","iopub.execute_input":"2026-03-06T03:03:28.612681Z","iopub.status.idle":"2026-03-06T03:03:28.621954Z","shell.execute_reply.started":"2026-03-06T03:03:28.612654Z","shell.execute_reply":"2026-03-06T03:03:28.621263Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def make_tf_dataset(x0_list, x1_list, x2_list, y_arr,\n                    batch_size=32, shuffle=False, T_FIXED=60):\n    W = IMG_SIZE[0]                 # 256\n    def gen():\n        for x0, x1, x2, y in zip(x0_list, x1_list, x2_list, y_arr):\n            yield {\"inp_ant0\": x0, \"inp_ant1\": x1, \"inp_ant2\": x2}, y\n\n    sig = (\n        {\n            \"inp_ant0\": tf.TensorSpec(shape=(W, None, 1), dtype=tf.float32),\n            \"inp_ant1\": tf.TensorSpec(shape=(W, None, 1), dtype=tf.float32),\n            \"inp_ant2\": tf.TensorSpec(shape=(W, None, 1), dtype=tf.float32),\n        },\n        tf.TensorSpec(shape=(NUM_CLASSES,), dtype=tf.float32)\n    )\n    ds = tf.data.Dataset.from_generator(gen, output_signature=sig)\n    if shuffle:\n        ds = ds.shuffle(buffer_size=len(y_arr))\n\n    # pad to longest sequence in the batch\n    ds = ds.padded_batch(\n        batch_size,\n        padded_shapes=(\n            {\"inp_ant0\": (W, None, 1),\n             \"inp_ant1\": (W, None, 1),\n             \"inp_ant2\": (W, None, 1)},\n            (NUM_CLASSES,)\n        ),\n        padding_values=(\n            {\"inp_ant0\": 0.0, \"inp_ant1\": 0.0, \"inp_ant2\": 0.0},\n            0.0\n        )\n    )\n\n    def cutpad(x, y):\n        def fix(v):\n            # v: [B, W, T, C]\n            t = tf.shape(v)[2]\n            v = v[:, :, :T_FIXED, :]                 # truncate time axis\n            pad = T_FIXED - t\n            pad = tf.maximum(pad, 0)\n            # pad along the time axis (axis‑2)\n            return tf.pad(v, [[0,0],[0,0],[0,pad],[0,0]])\n        return {k: fix(v) for k, v in x.items()}, y\n\n    ds = ds.map(cutpad, num_parallel_calls=tf.data.AUTOTUNE)\n    return ds.prefetch(tf.data.AUTOTUNE)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:03:28.622876Z","iopub.execute_input":"2026-03-06T03:03:28.623052Z","iopub.status.idle":"2026-03-06T03:03:28.637040Z","shell.execute_reply.started":"2026-03-06T03:03:28.623035Z","shell.execute_reply":"2026-03-06T03:03:28.636412Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"x0_tr, x1_tr, x2_tr, y_tr, ids_tr = build_md_arrays(train_samples, is_train=True)\nx0_val, x1_val, x2_val, y_val, ids_val = build_md_arrays(val_samples, is_train=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:03:28.637864Z","iopub.execute_input":"2026-03-06T03:03:28.638120Z","iopub.status.idle":"2026-03-06T03:06:59.237501Z","shell.execute_reply.started":"2026-03-06T03:03:28.638100Z","shell.execute_reply":"2026-03-06T03:06:59.236704Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_ds = make_tf_dataset(x0_tr, x1_tr, x2_tr, y_tr,\n                           batch_size=BATCH_SIZE, shuffle=True, T_FIXED=32)\nval_ds = make_tf_dataset(x0_val, x1_val, x2_val, y_val,\n                           batch_size=BATCH_SIZE, shuffle=False, T_FIXED=32)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:06:59.239790Z","iopub.execute_input":"2026-03-06T03:06:59.240083Z","iopub.status.idle":"2026-03-06T03:07:02.176794Z","shell.execute_reply.started":"2026-03-06T03:06:59.240059Z","shell.execute_reply":"2026-03-06T03:07:02.176008Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"for bx,by in train_ds.take(1):\n    print(bx['inp_ant0'].shape,\n          bx['inp_ant1'].shape,\n          bx['inp_ant2'].shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:07:02.177785Z","iopub.execute_input":"2026-03-06T03:07:02.178072Z","iopub.status.idle":"2026-03-06T03:07:07.083017Z","shell.execute_reply.started":"2026-03-06T03:07:02.178045Z","shell.execute_reply":"2026-03-06T03:07:07.082264Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def make_branch_enhanced(name):\n    inp = Input(shape=INPUT_SHAPE, name=f\"inp_{name}\")\n\n    x   = Conv2D(64, (3,3), padding='same', use_bias=False, kernel_regularizer=l2(WD))(inp)\n    x   = BatchNormalization()(x)\n    x   = ReLU()(x)\n    x   = MaxPool2D((2,2))(x)\n\n    x   = Conv2D(64, (3,3), padding='same', use_bias=False, kernel_regularizer=l2(WD))(x)\n    x   = BatchNormalization()(x)\n    x   = ReLU()(x)\n    x   = MaxPool2D((2,2))(x)\n\n    x   = Conv2D(128, (3,3), padding='same', use_bias=False, kernel_regularizer=l2(WD))(x)\n    x   = BatchNormalization()(x)\n    x   = ReLU()(x)\n    x   = MaxPool2D((2,2))(x)\n\n    x   = Conv2D(128, (3,3), padding='same', use_bias=False, kernel_regularizer=l2(WD))(x)\n    x   = BatchNormalization()(x)\n    x   = ReLU()(x)\n    x   = MaxPool2D((2,2))(x)\n\n    x   = GlobalAveragePooling2D()(x)\n    return inp, x\n\ndef build_md_cnn_enhanced(num_classes=NUM_CLASSES, lr=LR):\n    inp0, f0 = make_branch_enhanced(\"ant0\")\n    inp1, f1 = make_branch_enhanced(\"ant1\")\n    inp2, f2 = make_branch_enhanced(\"ant2\")\n\n    diff01 = Lambda(lambda t: K.abs(t[0] - t[1]), name=\"diff01\")([f0, f1])\n    diff02 = Lambda(lambda t: K.abs(t[0] - t[1]), name=\"diff02\")([f0, f2])\n    diff12 = Lambda(lambda t: K.abs(t[0] - t[1]), name=\"diff12\")([f1, f2])\n\n    prod01 = Lambda(lambda t: t[0] * t[1], name=\"prod01\")([f0, f1])\n    prod02 = Lambda(lambda t: t[0] * t[1], name=\"prod02\")([f0, f2])\n    prod12 = Lambda(lambda t: t[0] * t[1], name=\"prod12\")([f1, f2])\n\n    merged = Concatenate(name=\"fusion\")([\n        f0, f1, f2,\n        diff01, diff02, diff12,\n        prod01, prod02, prod12\n    ])\n    # merged = Concatenate(name=\"fusion\")([\n    #     f0, f1, f2,\n    # ])\n\n    x = Dense(256, use_bias=False, kernel_regularizer=l2(WD))(merged)\n    x = BatchNormalization()(x)\n    x = ReLU()(x)\n    x = Dropout(0.5)(x)\n\n    x = Dense(128, use_bias=False, kernel_regularizer=l2(WD))(x)\n    x = BatchNormalization()(x)\n    x = ReLU()(x)\n    x = Dropout(0.5)(x)\n\n    out = Dense(num_classes, activation='softmax', name=\"md_output\")(x)\n\n    model = models.Model(inputs=[inp0, inp1, inp2],\n                         outputs=out,\n                         name=\"RelationalMD_CNN_Enhanced\")\n    model.compile(\n        optimizer=AdamW(learning_rate=lr,weight_decay=WD,clipnorm=1.0),\n        loss='categorical_crossentropy',\n        metrics=['accuracy']\n    )\n    return model\n\nmd_model = build_md_cnn_enhanced()\nmd_model.summary()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:07:07.083940Z","iopub.execute_input":"2026-03-06T03:07:07.084211Z","iopub.status.idle":"2026-03-06T03:07:08.104301Z","shell.execute_reply.started":"2026-03-06T03:07:07.084183Z","shell.execute_reply":"2026-03-06T03:07:08.103655Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ───────────────────────────────────────────────────────────────────────────────\n# 4. CALLBACK: Save model every 5 epochs with validation accuracy in filename\n# ───────────────────────────────────────────────────────────────────────────────\n\n# class SaveEveryFive(tf.keras.callbacks.Callback):\n#     def on_epoch_end(self, epoch, logs=None):\n#         # logs['val_accuracy'] contains the validation accuracy of this epoch\n#         if (epoch + 1) % 5 == 0:\n#             val_acc = logs.get('val_accuracy', 0.0)\n#             filename = f\"epoch_{epoch+1:03d}_valAcc_{val_acc:.4f}.h5\"\n#             filepath = os.path.join(CHECKPOINT_DIR, filename)\n#             self.model.save(filepath)\n#             print(f\"\\n  → Saved checkpoint: {filepath}\")\nbest_ckpt = callbacks.ModelCheckpoint(\n    filepath=os.path.join(CHECKPOINT_DIR, \"best_model.h5\"),\n    monitor='val_accuracy',\n    verbose=1,\n    save_best_only=True,\n    mode='max'\n)\nreduce_lr = callbacks.ReduceLROnPlateau(\n    monitor='val_loss', factor=0.5, patience=5, verbose=1, min_lr=1e-6\n)\nearly_stop = callbacks.EarlyStopping(\n    monitor='val_loss', patience=20, restore_best_weights=False, verbose=1\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:07:08.105113Z","iopub.execute_input":"2026-03-06T03:07:08.105352Z","iopub.status.idle":"2026-03-06T03:07:08.110715Z","shell.execute_reply.started":"2026-03-06T03:07:08.105331Z","shell.execute_reply":"2026-03-06T03:07:08.109864Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# save_every_five = SaveEveryFive()\n\n# ───────────────────────────────────────────────────────────────────────────────\n# 5. TRAINING\n# ───────────────────────────────────────────────────────────────────────────────\n\n# history = md_model.fit(\n#     [x0_tr, x1_tr, x2_tr], y_tr,\n#     validation_data=([x0_val, x1_val, x2_val], y_val),\n#     epochs=EPOCHS,\n#     batch_size=BATCH_SIZE,\n#     shuffle=True,\n#     callbacks=[reduce_lr, early_stop, best_ckpt]\n# )\n\nhistory = md_model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=EPOCHS,\n    callbacks=[reduce_lr, early_stop, best_ckpt]\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:07:08.111441Z","iopub.execute_input":"2026-03-06T03:07:08.111688Z","iopub.status.idle":"2026-03-06T03:39:55.402195Z","shell.execute_reply.started":"2026-03-06T03:07:08.111668Z","shell.execute_reply":"2026-03-06T03:39:55.401470Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ───────────────────────────────────────────────────────────────────────────────\n# 9. PLOT TRAINING HISTORY\n# ───────────────────────────────────────────────────────────────────────────────\n\ndef plot_training_history(history, save_dir=RESULTS_DIR):\n    epochs_range = range(1, len(history.history['loss']) + 1)\n\n    train_loss = history.history['loss']\n    val_loss   = history.history['val_loss']\n    train_acc  = history.history['accuracy']\n    val_acc    = history.history['val_accuracy']\n\n    # Tìm best epoch\n    best_val_acc_epoch = int(np.argmax(val_acc)) + 1\n    best_val_loss_epoch = int(np.argmin(val_loss)) + 1\n\n    fig = plt.figure(figsize=(16, 6))\n    fig.suptitle(\"History\", fontsize=14, fontweight='bold')\n    gs = gridspec.GridSpec(1, 2, figure=fig, wspace=0.3)\n\n    # ── Plot 1: Loss ──────────────────────────────────────────────────────────\n    ax1 = fig.add_subplot(gs[0])\n    ax1.plot(epochs_range, train_loss, 'b-o', markersize=3,\n             linewidth=1.5, label='Train Loss')\n    ax1.plot(epochs_range, val_loss,   'r-o', markersize=3,\n             linewidth=1.5, label='Val Loss')\n    ax1.axvline(x=best_val_loss_epoch, color='gray', linestyle='--',\n                linewidth=1, alpha=0.7, label=f'Best Val Loss (ep {best_val_loss_epoch})')\n    ax1.scatter([best_val_loss_epoch], [val_loss[best_val_loss_epoch - 1]],\n                color='red', s=80, zorder=5)\n    ax1.annotate(f\"{val_loss[best_val_loss_epoch - 1]:.4f}\",\n                 xy=(best_val_loss_epoch, val_loss[best_val_loss_epoch - 1]),\n                 xytext=(8, 8), textcoords='offset points',\n                 fontsize=9, color='red')\n    ax1.set_title(\"Loss\", fontsize=12)\n    ax1.set_xlabel(\"Epoch\")\n    ax1.set_ylabel(\"Loss\")\n    ax1.legend(fontsize=9)\n    ax1.grid(True, alpha=0.3)\n    ax1.set_xlim([1, len(epochs_range)])\n\n    # ── Plot 2: Accuracy ──────────────────────────────────────────────────────\n    ax2 = fig.add_subplot(gs[1])\n    ax2.plot(epochs_range, train_acc, 'b-o', markersize=3,\n             linewidth=1.5, label='Train Accuracy')\n    ax2.plot(epochs_range, val_acc,   'r-o', markersize=3,\n             linewidth=1.5, label='Val Accuracy')\n    ax2.axvline(x=best_val_acc_epoch, color='gray', linestyle='--',\n                linewidth=1, alpha=0.7, label=f'Best Val Acc (ep {best_val_acc_epoch})')\n    ax2.scatter([best_val_acc_epoch], [val_acc[best_val_acc_epoch - 1]],\n                color='red', s=80, zorder=5)\n    ax2.annotate(f\"{val_acc[best_val_acc_epoch - 1]:.4f}\",\n                 xy=(best_val_acc_epoch, val_acc[best_val_acc_epoch - 1]),\n                 xytext=(8, -14), textcoords='offset points',\n                 fontsize=9, color='red')\n    ax2.set_title(\"Accuracy\", fontsize=12)\n    ax2.set_xlabel(\"Epoch\")\n    ax2.set_ylabel(\"Accuracy\")\n    ax2.legend(fontsize=9)\n    ax2.grid(True, alpha=0.3)\n    ax2.set_xlim([1, len(epochs_range)])\n    ax2.set_ylim([0, 1.05])\n        # ── Lưu file ─────────────────────────────────────────────────────────────\n    plot_path = os.path.join(save_dir, \"training_history.png\")\n    plt.savefig(plot_path, dpi=150, bbox_inches='tight')\n    plt.show()\n    print(f\"Plot saved: {plot_path}\")\n\n    # ── In summary ───────────────────────────────────────────────────────────\n    print(f\"\\n{'─'*45}\")\n    print(f\"  Total epochs trained : {len(epochs_range)}\")\n    print(f\"  Best Val Accuracy    : {max(val_acc):.4f}  (epoch {best_val_acc_epoch})\")\n    print(f\"  Best Val Loss        : {min(val_loss):.4f}  (epoch {best_val_loss_epoch})\")\n    print(f\"  Final Train Accuracy : {train_acc[-1]:.4f}\")\n    print(f\"  Final Val Accuracy   : {val_acc[-1]:.4f}\")\n    print(f\"{'─'*45}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:39:55.403390Z","iopub.execute_input":"2026-03-06T03:39:55.403699Z","iopub.status.idle":"2026-03-06T03:39:55.446799Z","shell.execute_reply.started":"2026-03-06T03:39:55.403675Z","shell.execute_reply":"2026-03-06T03:39:55.446108Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"plot_training_history(history, save_dir=RESULTS_DIR)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-06T03:39:55.447543Z","iopub.execute_input":"2026-03-06T03:39:55.448812Z","iopub.status.idle":"2026-03-06T03:39:56.305546Z","shell.execute_reply.started":"2026-03-06T03:39:55.448782Z","shell.execute_reply":"2026-03-06T03:39:56.304804Z"}},"outputs":[],"execution_count":null}]}