{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.12"},"kaggle":{"accelerator":"none","dataSources":[{"sourceType":"competition","sourceId":24800,"datasetId":1042002,"databundleVersionId":1831594},{"sourceType":"modelInstanceVersion","sourceId":862251,"databundleVersionId":17123471,"modelInstanceId":655500,"modelId":667473},{"sourceType":"kernelVersion","sourceId":317475370}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false},"papermill":{"default_parameters":{},"duration":34788.854859,"end_time":"2026-05-08T09:15:26.644102+00:00","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-05-07T23:35:37.789243+00:00","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install pandas numpy matplotlib seaborn opencv-python tqdm pydicom scikit-learn tensorflow","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:35:40.068911Z","iopub.status.busy":"2026-05-07T23:35:40.06862Z","iopub.status.idle":"2026-05-07T23:35:44.522247Z","shell.execute_reply":"2026-05-07T23:35:44.521537Z"},"papermill":{"duration":4.45956,"end_time":"2026-05-07T23:35:44.524228+00:00","exception":false,"start_time":"2026-05-07T23:35:40.064668+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport pandas as pd\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nimport warnings\nimport cv2\nfrom tqdm import tqdm\nimport pydicom\nfrom pydicom.pixel_data_handlers.util import apply_voi_lut\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.preprocessing import MultiLabelBinarizer\nimport tensorflow as tf\nfrom tensorflow.keras import layers, models, optimizers, callbacks, applications, regularizers\nfrom sklearn.metrics import precision_recall_fscore_support, roc_curve, auc\nimport matplotlib.patches as mpatches\nfrom sklearn.utils.class_weight import compute_sample_weight","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:35:44.531142Z","iopub.status.busy":"2026-05-07T23:35:44.530602Z","iopub.status.idle":"2026-05-07T23:36:11.085489Z","shell.execute_reply":"2026-05-07T23:36:11.084825Z"},"papermill":{"duration":26.560146,"end_time":"2026-05-07T23:36:11.08716+00:00","exception":false,"start_time":"2026-05-07T23:35:44.527014+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"warnings.filterwarnings('ignore')\n\n# plot style\nplt.rcParams['font.sans-serif'] = ['DejaVu Sans', 'Arial', 'Helvetica', 'sans-serif']  \nplt.rcParams['axes.unicode_minus'] = False\nplt.rcParams['figure.dpi'] = 120\ntry:\n    plt.style.use('seaborn-v0_8-whitegrid') \nexcept:\n    pass","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:36:11.094319Z","iopub.status.busy":"2026-05-07T23:36:11.093572Z","iopub.status.idle":"2026-05-07T23:36:11.097989Z","shell.execute_reply":"2026-05-07T23:36:11.097411Z"},"papermill":{"duration":0.00934,"end_time":"2026-05-07T23:36:11.099321+00:00","exception":false,"start_time":"2026-05-07T23:36:11.089981+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# data path \nDATA_DIR = \"/kaggle/input/competitions/vinbigdata-chest-xray-abnormalities-detection\"                                             \nTRAIN_DIR = os.path.join(DATA_DIR, \"train\")\nTRAIN_CSV = os.path.join(DATA_DIR, \"train.csv\")\ntrain_df = pd.read_csv(TRAIN_CSV)\n\nprint(\"\\n Full Distribution of Original Dataset\")\nplt.figure(figsize=(15, 6))\ndist1 = train_df['class_name'].value_counts()\nsns.barplot(x=dist1.index, y=dist1.values, palette='viridis')\nplt.title('Original Dataset: Class Distribution', fontsize=14, fontweight='bold')\nplt.xticks(rotation=45, ha='right')\nfor i, v in enumerate(dist1.values):\n    plt.text(i, v + (max(dist1.values)*0.01), str(v), ha='center', fontsize=9)\nplt.tight_layout(); plt.show()","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:36:11.105737Z","iopub.status.busy":"2026-05-07T23:36:11.105431Z","iopub.status.idle":"2026-05-07T23:36:11.745021Z","shell.execute_reply":"2026-05-07T23:36:11.744088Z"},"papermill":{"duration":0.644941,"end_time":"2026-05-07T23:36:11.746986+00:00","exception":false,"start_time":"2026-05-07T23:36:11.102045+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Data filtering \nNUM_SAMPLE = 4000\nnp.random.seed(42)\n\nall_ids = train_df['image_id'].unique()\nimg_class_df = train_df.drop_duplicates('image_id')[['image_id']].copy()\nimg_class_df = img_class_df.set_index('image_id')\n\ndominant_class = (\n    train_df[train_df['class_name'] != 'No finding']\n    .groupby('image_id')['class_name']\n    .first()\n    .rename('dominant')\n)\nno_finding_ids = train_df[train_df['class_name'] == 'No finding']['image_id'].unique()\nnf_series = pd.Series('No finding', index=no_finding_ids, name='dominant')\ndominant_series = pd.concat([dominant_class, nf_series])\ndominant_series = dominant_series[~dominant_series.index.duplicated(keep='first')]\n\nclass_counts = dominant_series.value_counts()\ntotal = class_counts.sum()\nquota = (class_counts / total * NUM_SAMPLE).round().astype(int)\ndiff = NUM_SAMPLE - quota.sum()\nquota.iloc[0] += diff\n\nselected_ids = []\nfor cls, n in quota.items():\n    pool = dominant_series[dominant_series == cls].index.tolist()\n    pick = np.random.choice(pool, min(n, len(pool)), replace=False)\n    selected_ids.extend(pick)\n\nselected_image_ids = list(set(selected_ids))\nselected_df = train_df[train_df['image_id'].isin(selected_image_ids)].copy()\n\ndist2_df = selected_df.drop_duplicates(['image_id', 'class_name'])\ndist2 = dist2_df['class_name'].value_counts()\nplt.figure(figsize=(15, 6))\nsns.barplot(x=dist2.index, y=dist2.values, palette='magma')\nplt.title(f'Stratified Subsample (Images: {len(selected_image_ids)})', fontsize=14, fontweight='bold')\nplt.xticks(rotation=45, ha='right')\nfor i, v in enumerate(dist2.values):\n    plt.text(i, v + (max(dist2.values)*0.01), str(v), ha='center', fontsize=9)\nplt.tight_layout(); plt.show()","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:36:11.756833Z","iopub.status.busy":"2026-05-07T23:36:11.756614Z","iopub.status.idle":"2026-05-07T23:36:12.160566Z","shell.execute_reply":"2026-05-07T23:36:12.159672Z"},"papermill":{"duration":0.411093,"end_time":"2026-05-07T23:36:12.162025+00:00","exception":false,"start_time":"2026-05-07T23:36:11.750932+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# CLAHE Enhancement và ánh xạ màu cố định \ndisease_classes = sorted([c for c in train_df['class_name'].unique() if c != 'No finding'])\ncolor_palette = [\n    (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255), (0, 255, 255),\n    (255, 128, 0), (128, 0, 255), (0, 128, 128), (128, 128, 0), (128, 0, 0), (0, 0, 128),\n    (0, 128, 0), (255, 192, 203)\n]\nGLOBAL_COLOR_MAP = {cls: color_palette[i % len(color_palette)] for i, cls in enumerate(disease_classes)}\n\nprint(\"\\nDisease Color Legend\")\nplt.figure(figsize=(12, 3))\nfor i, (cls, color) in enumerate(GLOBAL_COLOR_MAP.items()):\n    plt.bar(i, 1, color=[c/255.0 for c in color], label=cls)\nplt.xticks(range(len(GLOBAL_COLOR_MAP)), GLOBAL_COLOR_MAP.keys(), rotation=45, ha='right')\nplt.yticks([]); plt.title(\"Legend: Color Mapping\"); plt.tight_layout(); plt.show()\n\ndef dicom_to_array(path):\n    try:\n        dicom = pydicom.dcmread(path); data = apply_voi_lut(dicom.pixel_array, dicom)\n        if dicom.PhotometricInterpretation == \"MONOCHROME1\": data = np.amax(data) - data\n        data = data - np.min(data)\n        if np.max(data) > 0: data = (data / np.max(data) * 255).astype(np.uint8)\n        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n        return clahe.apply(data)\n    except: return None\n\ndef draw_bboxes_on_large_image(img_array, bboxes):\n    img_display = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) if len(img_array.shape) == 2 else img_array.copy()\n    for bbox in bboxes:\n        cls = bbox['class_name']; color = GLOBAL_COLOR_MAP.get(cls, (255, 255, 255))\n        x1, y1, x2, y2 = int(bbox['x_min']), int(bbox['y_min']), int(bbox['x_max']), int(bbox['y_max'])\n        cv2.rectangle(img_display, (x1, y1), (x2, y2), color, 8)\n        (w, h), _ = cv2.getTextSize(cls, cv2.FONT_HERSHEY_SIMPLEX, 1.5, 3)\n        cv2.rectangle(img_display, (x1, y1 - h - 20), (x1 + w, y1), color, -1)\n        cv2.putText(img_display, cls, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (255, 255, 255), 3)\n    return img_display\n","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:36:12.172917Z","iopub.status.busy":"2026-05-07T23:36:12.172641Z","iopub.status.idle":"2026-05-07T23:36:12.31721Z","shell.execute_reply":"2026-05-07T23:36:12.316383Z"},"papermill":{"duration":0.151987,"end_time":"2026-05-07T23:36:12.318815+00:00","exception":false,"start_time":"2026-05-07T23:36:12.166828+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#data pipeline\n\nimport gc\n\nIMG_SIZE = 224\nBATCH_SIZE = 16          \nAUTOTUNE = tf.data.AUTOTUNE\nNUM_PARALLEL_CALLS = 2  \n\n# Build dictionaries for labels and bounding boxes\nimage_to_labels, image_to_bboxes = {}, {}\nfor img_id in selected_image_ids:\n    img_data = selected_df[selected_df['image_id'] == img_id]\n\n    lbls = img_data[img_data['class_name'] != 'No finding']['class_name'].unique()\n    image_to_labels[img_id] = list(lbls) if len(lbls) > 0 else ['No finding']\n\n    boxes = []\n    for _, row in img_data.iterrows():\n        if row['class_name'] != 'No finding' and not pd.isna(row['x_min']):\n            boxes.append({\n                'class_name': row['class_name'],\n                'x_min': row['x_min'],\n                'y_min': row['y_min'],\n                'x_max': row['x_max'],\n                'y_max': row['y_max']\n            })\n    image_to_bboxes[img_id] = boxes\n\n# Keep only valid image paths and label lists\nfinal_ids, img_paths, label_lists = [], [], []\nfor img_id in selected_image_ids:\n    path = os.path.join(TRAIN_DIR, f\"{img_id}.dicom\")\n    if os.path.exists(path):\n        final_ids.append(img_id)\n        img_paths.append(path)\n        label_lists.append(image_to_labels[img_id])\n\nmlb = MultiLabelBinarizer()\ny_all = mlb.fit_transform(label_lists).astype(\"float32\")\nlabel_names = mlb.classes_\n\n# Split paths, not image arrays\ntrain_paths, val_paths, y_train, y_val, ids_train, ids_val = train_test_split(\n    np.array(img_paths),\n    y_all,\n    np.array(final_ids),\n    test_size=0.2,\n    random_state=42\n)\n\n# Class imbalance weights\nn_samples = y_train.shape[0]\npos_counts = y_train.sum(axis=0)\nneg_counts = n_samples - pos_counts\npos_weight = neg_counts / np.maximum(pos_counts, 1)\npos_weight_tensor = tf.constant(pos_weight, dtype=tf.float32)\n\ndef load_dicom_image_py(path):\n    \"\"\"Load one DICOM file only when the current batch needs it.\"\"\"\n    path = path.numpy().decode(\"utf-8\")\n    arr = dicom_to_array(path)\n    if arr is None:\n        arr = np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.uint8)\n    img = cv2.resize(arr, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_AREA)\n    img = np.stack([img, img, img], axis=-1).astype(\"float32\") / 255.0\n    return img\n\ndef load_dicom_tf(path, label):\n    img = tf.py_function(load_dicom_image_py, [path], tf.float32)\n    img.set_shape((IMG_SIZE, IMG_SIZE, 3))\n    label.set_shape((len(label_names),))\n    return img, label\n\n# Keras preprocessing layer outside map to avoid repeatedly creating a new layer per image\nrotation_layer = tf.keras.layers.RandomRotation(0.03)\n\ndef augment_tf(image, label):\n    image = tf.image.random_flip_left_right(image)\n    image = tf.image.random_brightness(image, max_delta=0.10)\n    image = tf.image.random_contrast(image, lower=0.90, upper=1.10)\n    image = rotation_layer(tf.expand_dims(image, 0), training=True)[0]\n    image = tf.clip_by_value(image, 0.0, 1.0)\n    return image, label\n\ntrain_ds = tf.data.Dataset.from_tensor_slices((train_paths, y_train))\ntrain_ds = (\n    train_ds\n    .shuffle(buffer_size=len(train_paths), seed=42, reshuffle_each_iteration=True)\n    .map(load_dicom_tf, num_parallel_calls=NUM_PARALLEL_CALLS)\n    .map(augment_tf, num_parallel_calls=NUM_PARALLEL_CALLS)\n    .batch(BATCH_SIZE)\n    .prefetch(1)          \n)\n\nval_ds = tf.data.Dataset.from_tensor_slices((val_paths, y_val))\nval_ds = (\n    val_ds\n    .map(load_dicom_tf, num_parallel_calls=NUM_PARALLEL_CALLS)\n    .batch(BATCH_SIZE)\n    .prefetch(1)\n)\n\nprint(f\"Valid images: {len(final_ids)}\")\nprint(f\"Train: {len(train_paths)} | Val: {len(val_paths)}\")\nprint(f\"Classes: {list(label_names)}\")\n\n# Preview only a few original images on demand.\nplt.figure(figsize=(20, 10))\npreview_n = min(8, len(final_ids))\nfor i in range(preview_n):\n    img_id = final_ids[i]\n    arr = dicom_to_array(os.path.join(TRAIN_DIR, f\"{img_id}.dicom\"))\n    if arr is None:\n        continue\n    bboxes = image_to_bboxes[img_id]\n    status = f\"Findings: {', '.join(list(set([b['class_name'] for b in bboxes])))[:15]}\" if bboxes else \"Normal\"\n    res = draw_bboxes_on_large_image(arr, bboxes)\n    plt.subplot(2, 4, i+1)\n    plt.imshow(res)\n    plt.title(f\"ID: {img_id[:6]}\\n{status}\", fontsize=9)\n    plt.axis('off')\nplt.tight_layout()\nplt.show()\nplt.close('all')\n\ngc.collect()\n","metadata":{"execution":{"iopub.execute_input":"2026-05-07T10:55:19.062052Z","iopub.status.busy":"2026-05-07T10:55:19.06131Z","iopub.status.idle":"2026-05-07T10:56:01.328479Z","shell.execute_reply":"2026-05-07T10:56:01.327693Z"},"papermill":{"duration":42.337287,"end_time":"2026-05-07T10:56:01.392428+00:00","exception":false,"start_time":"2026-05-07T10:55:19.055141+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Memory-efficient data pipeline\n\n\nimport gc\n\nIMG_SIZE = 224\nBATCH_SIZE = 16          \nAUTOTUNE = tf.data.AUTOTUNE\nNUM_PARALLEL_CALLS = 2   \n\nimage_to_labels, image_to_bboxes = {}, {}\nfor img_id in selected_image_ids:\n    img_data = selected_df[selected_df['image_id'] == img_id]\n\n    lbls = img_data[img_data['class_name'] != 'No finding']['class_name'].unique()\n    image_to_labels[img_id] = list(lbls) if len(lbls) > 0 else ['No finding']\n\n    boxes = []\n    for _, row in img_data.iterrows():\n        if row['class_name'] != 'No finding' and not pd.isna(row['x_min']):\n            boxes.append({\n                'class_name': row['class_name'],\n                'x_min': row['x_min'],\n                'y_min': row['y_min'],\n                'x_max': row['x_max'],\n                'y_max': row['y_max']\n            })\n    image_to_bboxes[img_id] = boxes\n\n# Keep only valid image paths + label lists\nfinal_ids, img_paths, label_lists = [], [], []\nfor img_id in selected_image_ids:\n    path = os.path.join(TRAIN_DIR, f\"{img_id}.dicom\")\n    if os.path.exists(path):\n        final_ids.append(img_id)\n        img_paths.append(path)\n        label_lists.append(image_to_labels[img_id])\n\nmlb = MultiLabelBinarizer()\ny_all = mlb.fit_transform(label_lists).astype(\"float32\")\nlabel_names = mlb.classes_\n\n# Split paths, not image arrays\ntrain_paths, val_paths, y_train, y_val, ids_train, ids_val = train_test_split(\n    np.array(img_paths),\n    y_all,\n    np.array(final_ids),\n    test_size=0.2,\n    random_state=42\n)\n\n# Class imbalance weights\nn_samples = y_train.shape[0]\npos_counts = y_train.sum(axis=0)\nneg_counts = n_samples - pos_counts\npos_weight = neg_counts / np.maximum(pos_counts, 1)\npos_weight_tensor = tf.constant(pos_weight, dtype=tf.float32)\n\ndef load_dicom_image_py(path):\n    path = path.numpy().decode(\"utf-8\")\n    arr = dicom_to_array(path)\n    if arr is None:\n        arr = np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.uint8)\n    img = cv2.resize(arr, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_AREA)\n    img = np.stack([img, img, img], axis=-1).astype(\"float32\") / 255.0\n    return img\n\ndef load_dicom_tf(path, label):\n    img = tf.py_function(load_dicom_image_py, [path], tf.float32)\n    img.set_shape((IMG_SIZE, IMG_SIZE, 3))\n    label.set_shape((len(label_names),))\n    return img, label\n# Keras preprocessing layer outside map to avoid repeatedly creating a new layer per image\nrotation_layer = tf.keras.layers.RandomRotation(0.03)\ndef augment_tf(image, label):\n    image = tf.image.random_flip_left_right(image)\n    image = tf.image.random_brightness(image, max_delta=0.10)\n    image = tf.image.random_contrast(image, lower=0.90, upper=1.10)\n    image = rotation_layer(tf.expand_dims(image, 0), training=True)[0]\n    image = tf.clip_by_value(image, 0.0, 1.0)\n    return image, label\n\ntrain_ds = tf.data.Dataset.from_tensor_slices((train_paths, y_train))\ntrain_ds = (\n    train_ds\n    .shuffle(buffer_size=len(train_paths), seed=42, reshuffle_each_iteration=True)\n    .map(load_dicom_tf, num_parallel_calls=NUM_PARALLEL_CALLS)\n    .map(augment_tf, num_parallel_calls=NUM_PARALLEL_CALLS)\n    .batch(BATCH_SIZE)\n    .prefetch(1)          # use 1 instead of AUTOTUNE to reduce RAM pressure\n)\n\nval_ds = tf.data.Dataset.from_tensor_slices((val_paths, y_val))\nval_ds = (\n    val_ds\n    .map(load_dicom_tf, num_parallel_calls=NUM_PARALLEL_CALLS)\n    .batch(BATCH_SIZE)\n    .prefetch(1)\n)\n\nprint(f\"Valid images: {len(final_ids)}\")\nprint(f\"Train: {len(train_paths)} | Val: {len(val_paths)}\")\nprint(f\"Classes: {list(label_names)}\")\n\n# Preview only a few original images on demand.\nplt.figure(figsize=(20, 10))\npreview_n = min(8, len(final_ids))\nfor i in range(preview_n):\n    img_id = final_ids[i]\n    arr = dicom_to_array(os.path.join(TRAIN_DIR, f\"{img_id}.dicom\"))\n    if arr is None:\n        continue\n    bboxes = image_to_bboxes[img_id]\n    status = f\"Findings: {', '.join(list(set([b['class_name'] for b in bboxes])))[:15]}\" if bboxes else \"Normal\"\n    res = draw_bboxes_on_large_image(arr, bboxes)\n    plt.subplot(2, 4, i+1)\n    plt.imshow(res)\n    plt.title(f\"ID: {img_id[:6]}\\n{status}\", fontsize=9)\n    plt.axis('off')\nplt.tight_layout()\nplt.show()\nplt.close('all')\n\ngc.collect()\n","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:36:12.330623Z","iopub.status.busy":"2026-05-07T23:36:12.330406Z","iopub.status.idle":"2026-05-07T23:36:53.729824Z","shell.execute_reply":"2026-05-07T23:36:53.729006Z"},"papermill":{"duration":41.467907,"end_time":"2026-05-07T23:36:53.792043+00:00","exception":false,"start_time":"2026-05-07T23:36:12.324136+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# model training\nfrom tensorflow.keras import mixed_precision\nmixed_precision.set_global_policy(\"mixed_float16\")\n\ndef build_model(num_classes):\n    base = applications.DenseNet121(\n        weights='imagenet',\n        include_top=False,\n        input_shape=(IMG_SIZE, IMG_SIZE, 3)\n    )\n    base.trainable = False\n\n    x = layers.GlobalAveragePooling2D()(base.output)\n    x = layers.Dropout(0.4)(x)\n    x = layers.Dense(\n        256,\n        activation='relu',\n        kernel_regularizer=regularizers.l2(1e-4)\n    )(x)\n    x = layers.BatchNormalization(dtype=\"float32\")(x)\n    x = layers.Dropout(0.25)(x)\n\n    outputs = layers.Dense(\n        num_classes,\n        activation='sigmoid',\n        dtype='float32'\n    )(x)\n\n    return models.Model(inputs=base.input, outputs=outputs)\n\ndef weighted_binary_crossentropy(pos_weight):\n    def loss_fn(y_true, y_pred):\n        y_true = tf.cast(y_true, tf.float32)\n        y_pred = tf.cast(y_pred, tf.float32)\n        y_pred = tf.clip_by_value(y_pred, 1e-7, 1 - 1e-7)\n        loss = -(\n            pos_weight * y_true * tf.math.log(y_pred)\n            + (1 - y_true) * tf.math.log(1 - y_pred)\n        )\n        return tf.reduce_mean(loss)\n    return loss_fn\n\nmodel = build_model(len(label_names))\nmodel.compile(\n    optimizer=optimizers.Adam(1e-3),\n    loss=weighted_binary_crossentropy(pos_weight_tensor),\n    metrics=[\n        tf.keras.metrics.BinaryAccuracy(name='binary_accuracy'),\n        tf.keras.metrics.AUC(name='auc', multi_label=True, curve='ROC'),\n        tf.keras.metrics.AUC(name='pr_auc', multi_label=True, curve='PR')\n    ]\n)\n\ntrain_callbacks = [\n    callbacks.EarlyStopping(\n        monitor='val_pr_auc',\n        patience=4,\n        mode='max',\n        restore_best_weights=True\n    ),\n    callbacks.ReduceLROnPlateau(\n        monitor='val_pr_auc',\n        factor=0.2,\n        patience=2,\n        min_lr=1e-6,\n        mode='max',\n        verbose=1\n    ),\n    callbacks.ModelCheckpoint(\n        \"best_phase1.keras\",\n        monitor=\"val_pr_auc\",\n        mode=\"max\",\n        save_best_only=True\n    )\n]\n\nhistory_phase1 = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=10,\n    callbacks=train_callbacks\n)\n\nfor layer in model.layers:\n    layer.trainable = False\n\nfor layer in model.layers[-60:]:\n    if not isinstance(layer, layers.BatchNormalization):\n        layer.trainable = True\n\nmodel.compile(\n    optimizer=optimizers.Adam(1e-5),\n    loss=weighted_binary_crossentropy(pos_weight_tensor),\n    metrics=[\n        tf.keras.metrics.BinaryAccuracy(name='binary_accuracy'),\n        tf.keras.metrics.AUC(name='auc', multi_label=True, curve='ROC'),\n        tf.keras.metrics.AUC(name='pr_auc', multi_label=True, curve='PR')\n    ]\n)\n\ntrain_callbacks2 = [\n    callbacks.EarlyStopping(\n        monitor='val_pr_auc',\n        patience=6,\n        mode='max',\n        restore_best_weights=True\n    ),\n    callbacks.ReduceLROnPlateau(\n        monitor='val_pr_auc',\n        factor=0.2,\n        patience=3,\n        min_lr=1e-7,\n        mode='max',\n        verbose=1\n    ),\n    callbacks.ModelCheckpoint(\n        \"best_finetune.keras\",\n        monitor=\"val_pr_auc\",\n        mode=\"max\",\n        save_best_only=True\n    )\n]\n\nhistory_phase2 = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=10,\n    callbacks=train_callbacks2\n)\n\nhistory_combined = {}\nfor k in history_phase1.history:\n    history_combined[k] = history_phase1.history[k] + history_phase2.history.get(k, [])\n\nfig, axes = plt.subplots(1, 3, figsize=(20, 5))\naxes[0].plot(history_combined['loss'], label='Train')\naxes[0].plot(history_combined['val_loss'], label='Val')\naxes[0].set_title('Loss')\naxes[0].legend()\n\naxes[1].plot(history_combined['binary_accuracy'], label='Train')\naxes[1].plot(history_combined['val_binary_accuracy'], label='Val')\naxes[1].set_title('Binary Accuracy')\naxes[1].legend()\n\naxes[2].plot(history_combined['pr_auc'], label='Train')\naxes[2].plot(history_combined['val_pr_auc'], label='Val')\naxes[2].set_title('PR-AUC')\naxes[2].legend()\n\nplt.tight_layout()\nplt.show()\nplt.close(fig)\ngc.collect()\n","metadata":{"execution":{"iopub.execute_input":"2026-05-07T10:56:01.443753Z","iopub.status.busy":"2026-05-07T10:56:01.443225Z"},"papermill":{"duration":null,"end_time":null,"exception":false,"start_time":"2026-05-07T10:56:01.418573+00:00","status":"running"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Resume Phase 2 training from previous best_finetune.keras\nfrom tensorflow.keras import mixed_precision\nmixed_precision.set_global_policy(\"mixed_float16\")\n\nimport os\nimport glob\nimport json\nimport gc\n#redefine loss function \ndef weighted_binary_crossentropy(pos_weight):\n    def loss_fn(y_true, y_pred):\n        y_true = tf.cast(y_true, tf.float32)\n        y_pred = tf.cast(y_pred, tf.float32)\n        y_pred = tf.clip_by_value(y_pred, 1e-7, 1 - 1e-7)\n        loss = -(\n            pos_weight * y_true * tf.math.log(y_pred)\n            + (1 - y_true) * tf.math.log(1 - y_pred)\n        )\n        return tf.reduce_mean(loss)\n    return loss_fn\n\n# Find best_finetune.keras\nkeras_files = glob.glob(\"/kaggle/input/**/*.keras\", recursive=True)\nprint(\"Found Keras model files:\")\nfor f in keras_files:\n    print(\" -\", f)\n\ncandidate_models = [f for f in keras_files if os.path.basename(f) == \"best_finetune.keras\"]\n\nif len(candidate_models) == 0:\n    raise FileNotFoundError(\n        \"Cannot find best_finetune.keras in /kaggle/input. \"\n        \"Make sure the Kaggle Model or notebook output containing best_finetune.keras is added as Input.\"\n    )\n\nRESUME_MODEL_PATH = candidate_models[0]\nprint(\"\\nLoading model from:\", RESUME_MODEL_PATH)\n\nmodel = tf.keras.models.load_model(\n    RESUME_MODEL_PATH,\n    compile=False\n)\n\n# Continue Phase 2\nfor layer in model.layers:\n    layer.trainable = False\n\nfor layer in model.layers[-60:]:\n    if not isinstance(layer, layers.BatchNormalization):\n        layer.trainable = True\n\nprint(\"Total layers:\", len(model.layers))\nprint(\"Trainable layers:\", sum(layer.trainable for layer in model.layers))\n\nmodel.compile(\n    optimizer=optimizers.Adam(1e-5),\n    loss=weighted_binary_crossentropy(pos_weight_tensor),\n    metrics=[\n        tf.keras.metrics.BinaryAccuracy(name='binary_accuracy'),\n        tf.keras.metrics.AUC(name='auc', multi_label=True, curve='ROC'),\n        tf.keras.metrics.AUC(name='pr_auc', multi_label=True, curve='PR')\n    ]\n)\n\nresume_callbacks = [\n    callbacks.EarlyStopping(\n        monitor='val_pr_auc',\n        patience=4,\n        mode='max',\n        restore_best_weights=True\n    ),\n    callbacks.ReduceLROnPlateau(\n        monitor='val_pr_auc',\n        factor=0.2,\n        patience=2,\n        min_lr=1e-7,\n        mode='max',\n        verbose=1\n    ),\n    callbacks.ModelCheckpoint(\n        \"/kaggle/working/best_resume_finetune.keras\",\n        monitor=\"val_pr_auc\",\n        mode=\"max\",\n        save_best_only=True\n    )\n]\n\nhistory_resume = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=10,\n    callbacks=resume_callbacks\n)\n\n# Save final resumed model and weights.\nmodel.save(\"/kaggle/working/final_resume_finetune.keras\")\nmodel.save_weights(\"/kaggle/working/final_resume_finetune.weights.h5\")\n\n# Save resume history.\nwith open(\"/kaggle/working/resume_training_history.json\", \"w\") as f:\n    json.dump(history_resume.history, f)\n\n# Plot resume training history.\nfig, axes = plt.subplots(1, 3, figsize=(20, 5))\n\naxes[0].plot(history_resume.history['loss'], label='Train')\naxes[0].plot(history_resume.history['val_loss'], label='Val')\naxes[0].set_title('Resume Phase 2 Loss')\naxes[0].legend()\n\naxes[1].plot(history_resume.history['binary_accuracy'], label='Train')\naxes[1].plot(history_resume.history['val_binary_accuracy'], label='Val')\naxes[1].set_title('Resume Phase 2 Binary Accuracy')\naxes[1].legend()\n\naxes[2].plot(history_resume.history['pr_auc'], label='Train')\naxes[2].plot(history_resume.history['val_pr_auc'], label='Val')\naxes[2].set_title('Resume Phase 2 PR-AUC')\naxes[2].legend()\n\nplt.tight_layout()\nplt.savefig(\"/kaggle/working/resume_training_history.png\", dpi=200, bbox_inches=\"tight\")\nplt.show()\nplt.close(fig)\n\ngc.collect()\n\nprint(\"\\nFiles saved in /kaggle/working:\")\nfor f in os.listdir(\"/kaggle/working\"):\n    path = os.path.join(\"/kaggle/working\", f)\n    size_mb = os.path.getsize(path) / 1024 / 1024\n    print(f, round(size_mb, 2), \"MB\")\n","metadata":{"execution":{"iopub.execute_input":"2026-05-07T23:36:53.843616Z","iopub.status.busy":"2026-05-07T23:36:53.843207Z","iopub.status.idle":"2026-05-08T09:02:44.666977Z","shell.execute_reply":"2026-05-08T09:02:44.665717Z"},"papermill":{"duration":33950.851203,"end_time":"2026-05-08T09:02:44.668921+00:00","exception":false,"start_time":"2026-05-07T23:36:53.817718+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Phân tích chẩn đoán\ny_pred_proba = model.predict(val_ds, verbose=1)\n\nnf_idx = list(label_names).index('No finding')\nnorm_idx = [i for i, y in enumerate(y_val) if y[nf_idx] == 1 and np.sum(y) == 1]\ndiseased_idx = [i for i, y in enumerate(y_val) if np.sum(np.delete(y, nf_idx)) > 0]\n\ndef deep_analyze_sample(idx, case_label):\n    img_id = ids_val[idx]\n    pred_p = y_pred_proba[idx]\n    true_y = y_val[idx]\n\n    true_lbls = [\n        label_names[j]\n        for j, v in enumerate(true_y)\n        if v == 1 and label_names[j] != 'No finding'\n    ]\n    pred_lbls = [\n        label_names[j]\n        for j, p in enumerate(pred_p)\n        if p > 0.5 and label_names[j] != 'No finding'\n    ]\n\n    arr = dicom_to_array(val_paths[idx])\n    if arr is None:\n        print(f\"Cannot read image: {img_id}\")\n        return\n\n    fig, axes = plt.subplots(\n        1, 3,\n        figsize=(26, 10),\n        gridspec_kw={'width_ratios': [1.2, 1, 0.8]}\n    )\n\n    res = draw_bboxes_on_large_image(arr, image_to_bboxes.get(img_id, []))\n    axes[0].imshow(res)\n    axes[0].axis('off')\n    axes[0].set_title(\n        f\"[{case_label}] ID: {img_id[:10]}\",\n        fontsize=16,\n        fontweight='bold'\n    )\n\n    probs_show = sorted(\n        [\n            (label_names[i], pred_p[i])\n            for i in range(len(label_names))\n            if label_names[i] != 'No finding'\n        ],\n        key=lambda x: x[1],\n        reverse=True\n    )[:8]\n\n    axes[1].barh(\n        [x[0] for x in probs_show][::-1],\n        [x[1] for x in probs_show][::-1],\n        color=['#ff4d4d' if v[1] > 0.5 else '#7fb3d5' for v in probs_show][::-1]\n    )\n    axes[1].axvline(x=0.5, color='red', linestyle='--')\n    axes[1].set_xlim(0, 1.1)\n\n    axes[2].axis('off')\n    report = \"DIAGNOSTIC REPORT\\n\" + \"=\" * 25 + \"\\n[Ground Truth]:\\n\"\n    report += (\"\\n\".join([f\" - {l}\" for l in true_lbls]) if true_lbls else \" - Normal Case\")\n    report += \"\\n\\n[Model Prediction]:\\n\"\n    report += (\"\\n\".join([f\" - {l}\" for l in pred_lbls]) if pred_lbls else \" - Normal Case\")\n    report += \"\\n\\n[Verdict]: \"\n    report += \"SUCCESS\" if (set(true_lbls) == set(pred_lbls)) else \"REVIEW REQUIRED\"\n\n    axes[2].text(\n        0, 0.95,\n        report,\n        fontsize=18,\n        verticalalignment='top',\n        family='monospace',\n        bbox=dict(boxstyle=\"round,pad=1\", facecolor=\"white\")\n    )\n\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n    gc.collect()\n\nif len(norm_idx) >= 1:\n    deep_analyze_sample(norm_idx[0], \"NORMAL CASE\")\n\nif len(diseased_idx) >= 2:\n    deep_analyze_sample(diseased_idx[0], \"ABNORMAL 1\")\n    deep_analyze_sample(diseased_idx[1], \"ABNORMAL 2\")\n","metadata":{"papermill":{"duration":null,"end_time":null,"exception":null,"start_time":null,"status":"pending"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Phân tích chẩn đoán\n\n\ny_pred_proba = model.predict(val_ds, verbose=1)\n\nnf_idx = list(label_names).index('No finding')\nnorm_idx = [i for i, y in enumerate(y_val) if y[nf_idx] == 1 and np.sum(y) == 1]\ndiseased_idx = [i for i, y in enumerate(y_val) if np.sum(np.delete(y, nf_idx)) > 0]\n\ndef deep_analyze_sample(idx, case_label):\n    img_id = ids_val[idx]\n    pred_p = y_pred_proba[idx]\n    true_y = y_val[idx]\n\n    true_lbls = [\n        label_names[j]\n        for j, v in enumerate(true_y)\n        if v == 1 and label_names[j] != 'No finding'\n    ]\n    pred_lbls = [\n        label_names[j]\n        for j, p in enumerate(pred_p)\n        if p > 0.5 and label_names[j] != 'No finding'\n    ]\n\n    arr = dicom_to_array(val_paths[idx])\n    if arr is None:\n        print(f\"Cannot read image: {img_id}\")\n        return\n\n    fig, axes = plt.subplots(\n        1, 3,\n        figsize=(26, 10),\n        gridspec_kw={'width_ratios': [1.2, 1, 0.8]}\n    )\n\n    res = draw_bboxes_on_large_image(arr, image_to_bboxes.get(img_id, []))\n    axes[0].imshow(res)\n    axes[0].axis('off')\n    axes[0].set_title(\n        f\"[{case_label}] ID: {img_id[:10]}\",\n        fontsize=16,\n        fontweight='bold'\n    )\n\n    probs_show = sorted(\n        [\n            (label_names[i], pred_p[i])\n            for i in range(len(label_names))\n            if label_names[i] != 'No finding'\n        ],\n        key=lambda x: x[1],\n        reverse=True\n    )[:8]\n\n    axes[1].barh(\n        [x[0] for x in probs_show][::-1],\n        [x[1] for x in probs_show][::-1],\n        color=['#ff4d4d' if v[1] > 0.5 else '#7fb3d5' for v in probs_show][::-1]\n    )\n    axes[1].axvline(x=0.5, color='red', linestyle='--')\n    axes[1].set_xlim(0, 1.1)\n\n    axes[2].axis('off')\n    report = \"DIAGNOSTIC REPORT\\n\" + \"=\" * 25 + \"\\n[Ground Truth]:\\n\"\n    report += (\"\\n\".join([f\" - {l}\" for l in true_lbls]) if true_lbls else \" - Normal Case\")\n    report += \"\\n\\n[Model Prediction]:\\n\"\n    report += (\"\\n\".join([f\" - {l}\" for l in pred_lbls]) if pred_lbls else \" - Normal Case\")\n    report += \"\\n\\n[Verdict]: \"\n    report += \"SUCCESS\" if (set(true_lbls) == set(pred_lbls)) else \"REVIEW REQUIRED\"\n\n    axes[2].text(\n        0, 0.95,\n        report,\n        fontsize=18,\n        verticalalignment='top',\n        family='monospace',\n        bbox=dict(boxstyle=\"round,pad=1\", facecolor=\"white\")\n    )\n\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n    gc.collect()\n\nif len(norm_idx) >= 1:\n    deep_analyze_sample(norm_idx[0], \"NORMAL CASE\")\n\nif len(diseased_idx) >= 2:\n    deep_analyze_sample(diseased_idx[0], \"ABNORMAL 1\")\n    deep_analyze_sample(diseased_idx[1], \"ABNORMAL 2\")\n","metadata":{"execution":{"iopub.execute_input":"2026-05-08T09:02:44.884915Z","iopub.status.busy":"2026-05-08T09:02:44.884605Z","iopub.status.idle":"2026-05-08T09:15:14.226645Z","shell.execute_reply":"2026-05-08T09:15:14.225812Z"},"papermill":{"duration":749.462383,"end_time":"2026-05-08T09:15:14.241429+00:00","exception":false,"start_time":"2026-05-08T09:02:44.779046+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"try:\n    plt.figure(figsize=(15, 6)); p, r, _, _ = precision_recall_fscore_support(y_val, (y_pred_proba > 0.5).astype(int), average=None, zero_division=0)\n    valid = np.sum(y_val, axis=0) > 0; n_sub = [label_names[i] for i in range(len(label_names)) if valid[i] and label_names[i] != 'No finding']\n    p_s = [p[i] for i in range(len(label_names)) if valid[i] and label_names[i] != 'No finding']; r_s = [r[i] for i in range(len(label_names)) if valid[i] and label_names[i] != 'No finding']\n    plt.bar(np.arange(len(n_sub))-0.2, p_s, 0.4, label='Precision', color='#2c3e50'); plt.bar(np.arange(len(n_sub))+0.2, r_s, 0.4, label='Recall', color='#c0392b')\n    plt.xticks(np.arange(len(n_sub)), n_sub, rotation=35, ha='right'); plt.legend(); plt.savefig('thesis_performance_v2.png', dpi=300); plt.show()\nexcept: pass\n\nmodel.save('chest_xray_model.h5')\nprint(\"\\nSaved\")","metadata":{"execution":{"iopub.execute_input":"2026-05-08T09:15:14.526441Z","iopub.status.busy":"2026-05-08T09:15:14.525803Z","iopub.status.idle":"2026-05-08T09:15:15.899287Z","shell.execute_reply":"2026-05-08T09:15:15.89837Z"},"papermill":{"duration":1.51689,"end_time":"2026-05-08T09:15:15.90102+00:00","exception":false,"start_time":"2026-05-08T09:15:14.38413+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# metrics\nimport itertools\nfrom sklearn.metrics import (\n    f1_score, classification_report,\n    multilabel_confusion_matrix, roc_auc_score,\n    roc_curve, auc\n)\n\nbest_thresholds = np.zeros(len(label_names))\nfor cls_idx in range(len(label_names)):\n    best_f1, best_t = 0, 0.5\n    for t in np.arange(0.1, 0.9, 0.05):\n        pred_bin = (y_pred_proba[:, cls_idx] > t).astype(int)\n        f1 = f1_score(y_val[:, cls_idx], pred_bin, zero_division=0)\n        if f1 > best_f1:\n            best_f1, best_t = f1, t\n    best_thresholds[cls_idx] = best_t\n\ny_pred_bin = np.array([\n    (y_pred_proba[:, i] > best_thresholds[i]).astype(int)\n    for i in range(len(label_names))\n]).T\n\nf1_per_class  = f1_score(y_val, y_pred_bin, average=None,      zero_division=0)\nf1_macro      = f1_score(y_val, y_pred_bin, average='macro',   zero_division=0)\nf1_weighted   = f1_score(y_val, y_pred_bin, average='weighted',zero_division=0)\n\nprint(\"F1-SCORE PER CLASS\")\nfor name, score in zip(label_names, f1_per_class):\n    bar = \"█\" * int(score * 20)\n    print(f\"  {name:<35} {score:.4f}  {bar}\")\nprint(f\"  {'Macro F1':<35} {f1_macro:.4f}\")\nprint(f\"  {'Weighted F1':<35} {f1_weighted:.4f}\")\n\nprint(\"\\nCLASSIFICATION REPORT\")\nprint(\"=\" * 55)\nvalid_mask = y_val.sum(axis=0) > 0\nvalid_names = [label_names[i] for i in range(len(label_names)) if valid_mask[i]]\nprint(classification_report(\n    y_val[:, valid_mask],\n    y_pred_bin[:, valid_mask],\n    target_names=valid_names,\n    zero_division=0\n))\n\nmcm = multilabel_confusion_matrix(y_val, y_pred_bin)\nvalid_indices = [i for i in range(len(label_names)) if valid_mask[i]]\nn_valid = len(valid_indices)\ncols = 4\nrows_cm = (n_valid + cols - 1) // cols\n\nfig, axes = plt.subplots(rows_cm, cols, figsize=(cols * 4, rows_cm * 3.5))\naxes = axes.flatten()\n\nfor ax_idx, cls_idx in enumerate(valid_indices):\n    cm = mcm[cls_idx]\n    ax = axes[ax_idx]\n    im = ax.imshow(cm, interpolation='nearest', cmap='Blues')\n    ax.set_title(label_names[cls_idx], fontsize=9, fontweight='bold')\n    tick_marks = [0, 1]\n    ax.set_xticks(tick_marks); ax.set_xticklabels(['Pred 0', 'Pred 1'], fontsize=8)\n    ax.set_yticks(tick_marks); ax.set_yticklabels(['True 0', 'True 1'], fontsize=8)\n    for i, j in itertools.product(range(2), range(2)):\n        ax.text(j, i, str(cm[i, j]), ha='center', va='center',\n                color='white' if cm[i, j] > cm.max() / 2 else 'black', fontsize=11)\n    plt.colorbar(im, ax=ax)\n\nfor ax in axes[n_valid:]:\n    ax.axis('off')\n\nplt.suptitle('Confusion Matrix — Per Class', fontsize=13, fontweight='bold', y=1.01)\nplt.tight_layout()\nplt.savefig('confusion_matrix.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint(\"Saved: confusion_matrix.png\")\n\ncols_roc = 4\nrows_roc = (n_valid + cols_roc - 1) // cols_roc\nfig, axes = plt.subplots(rows_roc, cols_roc, figsize=(cols_roc * 4, rows_roc * 3.5))\naxes = axes.flatten()\n\nroc_aucs = {}\nfor ax_idx, cls_idx in enumerate(valid_indices):\n    name = label_names[cls_idx]\n    fpr, tpr, _ = roc_curve(y_val[:, cls_idx], y_pred_proba[:, cls_idx])\n    roc_auc_val  = auc(fpr, tpr)\n    roc_aucs[name] = roc_auc_val\n    ax = axes[ax_idx]\n    ax.plot(fpr, tpr, color='darkorange', lw=2, label=f'AUC = {roc_auc_val:.3f}')\n    ax.plot([0, 1], [0, 1], 'k--', lw=1)\n    ax.set_xlim([0, 1]); ax.set_ylim([0, 1.02])\n    ax.set_xlabel('FPR', fontsize=8); ax.set_ylabel('TPR', fontsize=8)\n    ax.set_title(name, fontsize=9, fontweight='bold')\n    ax.legend(loc='lower right', fontsize=8)\n\nfor ax in axes[n_valid:]:\n    ax.axis('off')\n\nplt.suptitle('ROC-AUC Curve — Per Class', fontsize=13, fontweight='bold', y=1.01)\nplt.tight_layout()\nplt.savefig('roc_auc_curves.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint(\"Saved: roc_auc_curves.png\")\n\nprint(\"SUMMARY — AUC PER CLASS\")\nfor name, val in sorted(roc_aucs.items(), key=lambda x: -x[1]):\n    bar = \"█\" * int(val * 20)\n    print(f\"  {name:<35} {val:.4f}  {bar}\")\nmacro_auc = roc_auc_score(y_val[:, valid_mask], y_pred_proba[:, valid_mask],\n                          average='macro', multi_class='ovr')\nprint(f\"  {'Macro AUC':<35} {macro_auc:.4f}\")","metadata":{"execution":{"iopub.execute_input":"2026-05-08T09:15:16.187518Z","iopub.status.busy":"2026-05-08T09:15:16.187092Z","iopub.status.idle":"2026-05-08T09:15:23.193668Z","shell.execute_reply":"2026-05-08T09:15:23.192688Z"},"papermill":{"duration":7.140469,"end_time":"2026-05-08T09:15:23.195076+00:00","exception":false,"start_time":"2026-05-08T09:15:16.054607+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null}]}