{"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.14"},"accelerator":"GPU"},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"8ee2ce30-67b1-4f4e-af16-92665f0c2d05","cell_type":"markdown","source":"# CIFAR-10 Kaggle Competition (From-Scratch CNN)\n\n\n","metadata":{}},{"id":"14ce6e16-163e-4d43-adcd-598ea1b1a0b9","cell_type":"markdown","source":"## 1. Setup & dependencies\n","metadata":{}},{"id":"4fa8e01b-ae1f-4101-90e7-2db778235a94","cell_type":"code","source":"# Install lightweight extras used for progress bars and 7z extraction.\n# On Kaggle these install into the notebook environment; locally they are optional\n# if already present (or available via PYTHONPATH=./.vendor).\nimport sys\nimport subprocess\n\ndef pip_install(*pkgs):\n    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *pkgs])\n\ntry:\n    import tqdm  # noqa: F401\nexcept ImportError:\n    pip_install(\"tqdm\")\n\ntry:\n    import py7zr  # noqa: F401\nexcept ImportError:\n    pip_install(\"py7zr\")\n\nprint(\"Python:\", sys.version)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"6e8e0223-9bdc-45ea-a41c-9a07e46629bc","cell_type":"code","source":"# Local helper: optional packages installed into ./.vendor (tfmetal-compatible)\nfrom pathlib import Path as _Path\nimport sys as _sys\n_vendor = _Path.cwd() / \".vendor\"\nif _vendor.exists():\n    _sys.path.insert(0, str(_vendor.resolve()))\n\nimport os\nimport glob\nimport math\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport tensorflow as tf\nfrom tensorflow import keras\nfrom tensorflow.keras import layers\n\nprint(\"TensorFlow:\", tf.__version__)\nprint(\"Keras:\", keras.__version__)\nprint(\"Devices:\", tf.config.list_physical_devices())\n\n# Prefer GPU when available (Apple Metal / discrete GPU).\ngpus = tf.config.list_physical_devices(\"GPU\")\nif gpus:\n    try:\n        for g in gpus:\n            tf.config.experimental.set_memory_growth(g, True)\n        print(f\"Using {len(gpus)} GPU(s):\", gpus)\n    except Exception as e:\n        print(\"GPU memory growth setup failed:\", e)\nelse:\n    print(\"No GPU detected — training will use CPU (slower).\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"c5fdc0b2-4875-4a3f-a0b4-01a2bf630c5e","cell_type":"markdown","source":"## 2. Paths & hyperparameters\n","metadata":{}},{"id":"78e83278-f960-40cc-a079-a564cb9f3ff7","cell_type":"code","source":"# Local workspace paths\nWORK_DIR = Path(\".\").resolve()\nDATA_DIR = WORK_DIR / \"data\"\nDATA_DIR.mkdir(parents=True, exist_ok=True)\n\nDATA_ROOT_CANDIDATES = [\n    DATA_DIR,\n    WORK_DIR,\n    WORK_DIR / \"Downloads\",\n    Path.home() / \"Downloads\",\n]\n\n\ndef find_test_archive():\n    for root in DATA_ROOT_CANDIDATES:\n        for name in (\"test.7z\", \"test.zip\"):\n            p = root / name\n            if p.exists():\n                return p\n    for root in DATA_ROOT_CANDIDATES:\n        if root.exists():\n            hits = list(root.rglob(\"test.7z\")) + list(root.rglob(\"test.zip\"))\n            if hits:\n                return hits[0]\n    return None\n\n\nTEST_ARCHIVE = find_test_archive()\nTEST_DIR = WORK_DIR / \"test\"\nSUBMISSION_PATH = WORK_DIR / \"submission.csv\"\nCHECKPOINT_PATH = WORK_DIR / \"best_resnet18_cifar10.keras\"\n\n# Kaggle class names (must match competition labels exactly)\nCLASS_NAMES = [\n    \"airplane\", \"automobile\", \"bird\", \"cat\", \"deer\",\n    \"dog\", \"frog\", \"horse\", \"ship\", \"truck\",\n]\nNUM_CLASSES = 10\n\n# Training config — tuned for >= 0.88 (typically ~0.92–0.94 with full schedule)\nSEED = 42\nBATCH_SIZE = 128\nEPOCHS = 80          # use 40–50 for a quicker run; 80 is safer for ≥0.88\nVAL_FRACTION = 0.1   # hold-out from official train for monitoring\nLEARNING_RATE = 0.1\nMOMENTUM = 0.9\nWEIGHT_DECAY = 5e-4\nLABEL_SMOOTHING = 0.1\n\nkeras.utils.set_random_seed(SEED)\nprint(\"WORK_DIR:\", WORK_DIR)\nprint(\"DATA_DIR:\", DATA_DIR)\nprint(\"TEST_ARCHIVE:\", TEST_ARCHIVE)\nprint(\"CHECKPOINT_PATH:\", CHECKPOINT_PATH)\nprint(\"EPOCHS:\", EPOCHS, \"BATCH_SIZE:\", BATCH_SIZE)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"575acaab-e5db-48f7-984b-3f760eefa403","cell_type":"markdown","source":"## 3. Load official CIFAR-10\n","metadata":{}},{"id":"b0326a29-5c76-4b0d-bea3-b0c24927d3a1","cell_type":"code","source":"(x_train_full, y_train_full), (x_test, y_test) = keras.datasets.cifar10.load_data()\ny_train_full = y_train_full.astype(\"int32\").reshape(-1)\ny_test = y_test.astype(\"int32\").reshape(-1)\n\n# float32 in [0, 1]\nx_train_full = x_train_full.astype(\"float32\") / 255.0\nx_test = x_test.astype(\"float32\") / 255.0\n\n# Train / validation split from official training set\nn = len(x_train_full)\nn_val = int(n * VAL_FRACTION)\nrng = np.random.default_rng(SEED)\nperm = rng.permutation(n)\nval_idx, train_idx = perm[:n_val], perm[n_val:]\n\nx_train, y_train = x_train_full[train_idx], y_train_full[train_idx]\nx_val, y_val = x_train_full[val_idx], y_train_full[val_idx]\n\nprint(f\"train: {x_train.shape}, val: {x_val.shape}, test: {x_test.shape}\")\nprint(\"class distribution (train):\", np.bincount(y_train))\n","metadata":{},"outputs":[],"execution_count":null},{"id":"6457e421-34ff-43f5-a6a2-151b534081bf","cell_type":"code","source":"# Spot-check a few training images\nfig, axes = plt.subplots(2, 8, figsize=(12, 3.5))\nfor ax, img, lab in zip(axes.ravel(), x_train[:16], y_train[:16]):\n    ax.imshow(img)\n    ax.set_title(CLASS_NAMES[int(lab)], fontsize=8)\n    ax.axis(\"off\")\nplt.suptitle(\"Official CIFAR-10 training samples\")\nplt.tight_layout()\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"d281089e-319e-443b-8b2a-a76d8d80c11d","cell_type":"markdown","source":"## 4. Data pipeline & augmentation\n\nAugmentations (train only): random flip, random translation/crop-like shifts, mild contrast jitter.  \nImages are standardized with CIFAR-10 channel means/stds.\n","metadata":{}},{"id":"b069e871-c39a-4980-8a78-fce56eb50484","cell_type":"code","source":"# CIFAR-10 per-channel stats (on [0,1] scale)\nMEAN = tf.constant([0.4914, 0.4822, 0.4465], dtype=tf.float32)\nSTD = tf.constant([0.2470, 0.2435, 0.2616], dtype=tf.float32)\n\ndef normalize(images):\n    return (images - MEAN) / STD\n\naugmenter = keras.Sequential(\n    [\n        layers.RandomFlip(\"horizontal\"),\n        layers.RandomTranslation(0.125, 0.125, fill_mode=\"reflect\"),\n        layers.RandomContrast(0.1),\n    ],\n    name=\"augmenter\",\n)\n\nAUTOTUNE = tf.data.AUTOTUNE\n\ndef make_ds(x, y, training=False, batch_size=BATCH_SIZE):\n    ds = tf.data.Dataset.from_tensor_slices((x, y))\n    if training:\n        ds = ds.shuffle(buffer_size=len(x), seed=SEED, reshuffle_each_iteration=True)\n    ds = ds.batch(batch_size)\n    if training:\n        ds = ds.map(\n            lambda imgs, labs: (normalize(augmenter(imgs, training=True)), labs),\n            num_parallel_calls=AUTOTUNE,\n        )\n    else:\n        ds = ds.map(\n            lambda imgs, labs: (normalize(imgs), labs),\n            num_parallel_calls=AUTOTUNE,\n        )\n    return ds.prefetch(AUTOTUNE)\n\ntrain_ds = make_ds(x_train, y_train, training=True)\nval_ds = make_ds(x_val, y_val, training=False)\ntest_ds = make_ds(x_test, y_test, training=False)\nprint(\"tf.data pipelines ready\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"27756343-9789-47bc-8f3e-8af53086711c","cell_type":"markdown","source":"## 5. Model: CIFAR ResNet-18 (random initialization)\n\nStandard ImageNet ResNet starts with a 7×7 stride-2 stem + max-pool, which is too aggressive for 32×32.  \nWe use the common CIFAR stem: **3×3 conv, stride 1, no max-pool**, then residual stages.  \n`kernel_regularizer` implements weight decay. **No pretrained weights.**\n","metadata":{}},{"id":"2eb18643-f23a-4522-968e-81f936408302","cell_type":"code","source":"def conv3x3(filters, stride=1):\n    return layers.Conv2D(\n        filters,\n        kernel_size=3,\n        strides=stride,\n        padding=\"same\",\n        use_bias=False,\n        kernel_initializer=\"he_normal\",\n        kernel_regularizer=keras.regularizers.l2(WEIGHT_DECAY),\n    )\n\n\ndef basic_block(x, filters, stride=1, name=None):\n    shortcut = x\n    y = conv3x3(filters, stride)(x)\n    y = layers.BatchNormalization()(y)\n    y = layers.ReLU()(y)\n    y = conv3x3(filters, 1)(y)\n    y = layers.BatchNormalization()(y)\n\n    if stride != 1 or shortcut.shape[-1] != filters:\n        shortcut = layers.Conv2D(\n            filters,\n            kernel_size=1,\n            strides=stride,\n            padding=\"same\",\n            use_bias=False,\n            kernel_initializer=\"he_normal\",\n            kernel_regularizer=keras.regularizers.l2(WEIGHT_DECAY),\n        )(shortcut)\n        shortcut = layers.BatchNormalization()(shortcut)\n\n    y = layers.Add()([shortcut, y])\n    y = layers.ReLU(name=name)(y)\n    return y\n\n\ndef resnet18_cifar(input_shape=(32, 32, 3), num_classes=10):\n    inputs = keras.Input(shape=input_shape)\n    x = conv3x3(64, stride=1)(inputs)\n    x = layers.BatchNormalization()(x)\n    x = layers.ReLU()(x)\n\n    # stages: [2, 2, 2, 2] basic blocks — ResNet-18\n    for filters, blocks, stride in [\n        (64, 2, 1),\n        (128, 2, 2),\n        (256, 2, 2),\n        (512, 2, 2),\n    ]:\n        for i in range(blocks):\n            s = stride if i == 0 else 1\n            x = basic_block(x, filters, stride=s)\n\n    x = layers.GlobalAveragePooling2D()(x)\n    outputs = layers.Dense(\n        num_classes,\n        activation=\"softmax\",\n        kernel_initializer=\"he_normal\",\n        kernel_regularizer=keras.regularizers.l2(WEIGHT_DECAY),\n        name=\"predictions\",\n    )(x)\n    return keras.Model(inputs, outputs, name=\"resnet18_cifar\")\n\n\nmodel = resnet18_cifar()\nmodel.summary()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"704b4615-d88d-4c52-b903-1f22873bb097","cell_type":"markdown","source":"## 6. Compile & train\n","metadata":{}},{"id":"bf9c3e10-0fed-480a-8861-9a7c458f5105","cell_type":"code","source":"# Compile with cosine LR schedule + optional label smoothing.\n# CategoricalCrossentropy + one-hot labels when LABEL_SMOOTHING > 0.\n\nsteps_per_epoch = math.ceil(len(x_train) / BATCH_SIZE)\ntotal_steps = steps_per_epoch * EPOCHS\n\nlr_schedule = keras.optimizers.schedules.CosineDecay(\n    initial_learning_rate=LEARNING_RATE,\n    decay_steps=total_steps,\n    alpha=0.001,\n)\n\noptimizer = keras.optimizers.SGD(\n    learning_rate=lr_schedule,\n    momentum=MOMENTUM,\n    nesterov=True,\n)\n\n\ndef make_ds_onehot(x, y, training=False, batch_size=BATCH_SIZE):\n    y_oh = keras.utils.to_categorical(y, NUM_CLASSES)\n    ds = tf.data.Dataset.from_tensor_slices((x, y_oh))\n    if training:\n        ds = ds.shuffle(buffer_size=len(x), seed=SEED, reshuffle_each_iteration=True)\n    ds = ds.batch(batch_size)\n    if training:\n        ds = ds.map(\n            lambda imgs, labs: (normalize(augmenter(imgs, training=True)), labs),\n            num_parallel_calls=AUTOTUNE,\n        )\n    else:\n        ds = ds.map(\n            lambda imgs, labs: (normalize(imgs), labs),\n            num_parallel_calls=AUTOTUNE,\n        )\n    return ds.prefetch(AUTOTUNE)\n\n\nif LABEL_SMOOTHING > 0:\n    train_ds = make_ds_onehot(x_train, y_train, training=True)\n    val_ds = make_ds_onehot(x_val, y_val, training=False)\n    test_ds = make_ds_onehot(x_test, y_test, training=False)\n    loss_fn = keras.losses.CategoricalCrossentropy(label_smoothing=LABEL_SMOOTHING)\nelse:\n    loss_fn = keras.losses.SparseCategoricalCrossentropy()\n\nmodel.compile(\n    optimizer=optimizer,\n    loss=loss_fn,\n    metrics=[\"accuracy\"],\n)\nprint(\n    \"Compiled:\", type(loss_fn).__name__,\n    \"| label_smoothing=\", LABEL_SMOOTHING,\n    \"| steps/epoch=\", steps_per_epoch,\n)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"1b738393-8a9c-40c7-ac4b-4a4a11f254fd","cell_type":"code","source":"callbacks = [\n    keras.callbacks.ModelCheckpoint(\n        filepath=str(CHECKPOINT_PATH),\n        monitor=\"val_accuracy\",\n        mode=\"max\",\n        save_best_only=True,\n        verbose=1,\n    ),\n    keras.callbacks.EarlyStopping(\n        monitor=\"val_accuracy\",\n        mode=\"max\",\n        patience=20,\n        restore_best_weights=True,\n        verbose=1,\n    ),\n]\n\nt0 = time.time()\nhistory = model.fit(\n    train_ds,\n    validation_data=val_ds,\n    epochs=EPOCHS,\n    callbacks=callbacks,\n    verbose=1,\n)\nprint(f\"Training wall time: {(time.time() - t0) / 60:.1f} min\")\n\n# Ensure best checkpoint weights are loaded\nif CHECKPOINT_PATH.exists():\n    model = keras.models.load_model(CHECKPOINT_PATH)\n    print(\"Loaded best checkpoint from\", CHECKPOINT_PATH)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"237ba5a7-a563-435f-af4c-d3b910b0a006","cell_type":"code","source":"# Plot learning curves\nhist = history.history\nfig, axes = plt.subplots(1, 2, figsize=(11, 4))\naxes[0].plot(hist[\"loss\"], label=\"train\")\naxes[0].plot(hist[\"val_loss\"], label=\"val\")\naxes[0].set_title(\"Loss\")\naxes[0].legend()\naxes[1].plot(hist[\"accuracy\"], label=\"train\")\naxes[1].plot(hist[\"val_accuracy\"], label=\"val\")\naxes[1].axhline(0.88, color=\"r\", linestyle=\"--\", label=\"0.88 target\")\naxes[1].set_title(\"Accuracy\")\naxes[1].legend()\nplt.tight_layout()\nplt.show()\n\nprint(f\"Best val accuracy: {max(hist['val_accuracy']):.4f}\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"fe4fe316-cb46-48b6-9715-800b47000a69","cell_type":"markdown","source":"## 7. Evaluate on official CIFAR-10 test set\n","metadata":{}},{"id":"7b5c5cf6-acc5-48ca-aba0-10f4aa376da1","cell_type":"code","source":"test_loss, test_acc = model.evaluate(test_ds, verbose=1)\nprint(f\"Official CIFAR-10 test accuracy: {test_acc:.4f}\")\nprint(f\"Official CIFAR-10 test loss:     {test_loss:.4f}\")\n\n","metadata":{},"outputs":[],"execution_count":null},{"id":"781885e7-41d5-4995-8054-8e3fe1b04516","cell_type":"markdown","source":"## 8. Get Kaggle `test.7z`, extract, and build `submission.csv`\n","metadata":{}},{"id":"be350f8f-ee3c-47c5-888f-ad13ebf101d4","cell_type":"code","source":"def ensure_kaggle_cli():\n    try:\n        import kaggle  # noqa: F401\n        return True\n    except ImportError:\n        try:\n            pip_install(\"kaggle\")\n            return True\n        except Exception as e:\n            print(\"Could not install kaggle CLI:\", e)\n            return False\n\n\ndef download_test_archive(dest_dir: Path):\n    \"\"\"Optional: download test.7z via Kaggle API into dest_dir.\"\"\"\n    dest_dir = Path(dest_dir)\n    dest_dir.mkdir(parents=True, exist_ok=True)\n    target = dest_dir / \"test.7z\"\n    if target.exists() and target.stat().st_size > 0:\n        return target\n\n    cred = Path.home() / \".kaggle\" / \"kaggle.json\"\n    if not cred.exists():\n        print(\n            \"test.7z not found and no ~/.kaggle/kaggle.json for auto-download.\\n\"\n            \"Download test.7z from https://www.kaggle.com/c/cifar-10/data\\n\"\n            \"then place it at: data/test.7z  and re-run this cell.\"\n        )\n        return None\n\n    if not ensure_kaggle_cli():\n        return None\n\n    print(\"Downloading test.7z from Kaggle competition cifar-10 ...\")\n    cmds = [\n        [\n            sys.executable, \"-m\", \"kaggle\", \"competitions\", \"download\",\n            \"-c\", \"cifar-10\", \"-f\", \"test.7z\", \"-p\", str(dest_dir),\n        ],\n        [\n            sys.executable, \"-m\", \"kaggle\", \"competitions\", \"download\",\n            \"-c\", \"cifar-10\", \"-p\", str(dest_dir),\n        ],\n    ]\n    last_err = None\n    for cmd in cmds:\n        try:\n            subprocess.check_call(cmd)\n            break\n        except Exception as e:\n            last_err = e\n            print(\"Download attempt failed:\", e)\n    else:\n        print(\"Kaggle download failed:\", last_err)\n        return None\n\n    if not target.exists():\n        import zipfile\n        for zpath in dest_dir.glob(\"*.zip\"):\n            with zipfile.ZipFile(zpath, \"r\") as zf:\n                members = [m for m in zf.namelist() if Path(m).name == \"test.7z\"]\n                for m in members:\n                    print(\"Extracting\", m, \"from\", zpath.name)\n                    zf.extract(m, path=dest_dir)\n                    extracted = dest_dir / m\n                    if extracted.exists() and extracted.resolve() != target.resolve():\n                        extracted.replace(target)\n        hits = list(dest_dir.rglob(\"test.7z\"))\n        if hits and not target.exists():\n            hits[0].replace(target)\n\n    if target.exists():\n        print(\"Ready:\", target, f\"({target.stat().st_size / 1e6:.1f} MB)\")\n        return target\n    print(\"Download finished but test.7z still missing under\", dest_dir)\n    return None\n\n\n# Refresh search (in case you just dropped test.7z into data/)\nTEST_ARCHIVE = find_test_archive()\nif TEST_ARCHIVE is None:\n    downloaded = download_test_archive(DATA_DIR)\n    if downloaded is not None:\n        TEST_ARCHIVE = downloaded\nprint(\"TEST_ARCHIVE:\", TEST_ARCHIVE)\n\n\ndef extract_test_archive(archive_path: Path, out_dir: Path):\n    \"\"\"Extract test images. Prefer py7zr; fall back to 7z CLI.\"\"\"\n    out_dir = Path(out_dir)\n    out_dir.mkdir(parents=True, exist_ok=True)\n\n    nested = out_dir / \"test\"\n    existing = list(nested.glob(\"*.png\")) if nested.exists() else []\n    if not existing:\n        existing = list(out_dir.glob(\"*.png\"))\n    if len(existing) >= 300000:\n        print(f\"Already extracted ({len(existing)} pngs) — skipping.\")\n        return nested if nested.exists() else out_dir\n\n    archive_path = Path(archive_path)\n    print(\"Extracting\", archive_path, \"->\", out_dir)\n\n    if archive_path.suffix == \".7z\":\n        try:\n            import py7zr\n            with py7zr.SevenZipFile(archive_path, mode=\"r\") as z:\n                z.extractall(path=out_dir)\n        except Exception as e:\n            print(\"py7zr failed:\", e)\n            for cmd in (\n                [\"7z\", \"x\", str(archive_path), f\"-o{out_dir}\", \"-y\"],\n                [\"7za\", \"x\", str(archive_path), f\"-o{out_dir}\", \"-y\"],\n            ):\n                try:\n                    subprocess.check_call(cmd)\n                    break\n                except Exception as e3:\n                    print(\"CLI failed:\", cmd, e3)\n            else:\n                raise RuntimeError(\n                    f\"Could not extract {archive_path}. Install py7zr \"\n                    f\"(pip install py7zr) or extract manually into ./test/\"\n                )\n    else:\n        import zipfile\n        with zipfile.ZipFile(archive_path, \"r\") as z:\n            z.extractall(out_dir)\n\n    if (out_dir / \"test\").exists():\n        return out_dir / \"test\"\n    pngs = list(out_dir.glob(\"*.png\"))\n    if pngs:\n        return out_dir\n    nested_dirs = list(out_dir.rglob(\"*.png\"))\n    if nested_dirs:\n        return nested_dirs[0].parent\n    raise FileNotFoundError(\"No PNG files found after extraction\")\n\n\nimage_dir = None\nif TEST_ARCHIVE is not None:\n    image_dir = extract_test_archive(TEST_ARCHIVE, WORK_DIR)\n    n_files = len(glob.glob(str(Path(image_dir) / \"*\")))\n    print(\"Image dir:\", image_dir, \"file count:\", n_files)\nelse:\n    for cand in [TEST_DIR, DATA_DIR / \"test\"]:\n        if cand.exists() and any(cand.glob(\"*.png\")):\n            image_dir = cand\n            print(\"Using existing extracted folder:\", image_dir)\n            break\n    if image_dir is None:\n        print(\n            \"WARNING: test.7z not found.\\n\"\n            \"Download it from https://www.kaggle.com/c/cifar-10/data\\n\"\n            \"and place it at data/test.7z, then re-run this cell.\"\n        )\n","metadata":{},"outputs":[],"execution_count":null},{"id":"f843dd7f-6621-4de9-ac06-91e1206a9987","cell_type":"code","source":"from PIL import Image\n\ndef load_kaggle_test_paths(image_dir: Path):\n    paths = sorted(image_dir.glob(\"*.png\"), key=lambda p: int(p.stem))\n    if not paths:\n        raise FileNotFoundError(f\"No PNGs in {image_dir}\")\n    ids = [int(p.stem) for p in paths]\n    print(f\"Found {len(paths)} test images (id {ids[0]} .. {ids[-1]})\")\n    return paths, ids\n\n\ndef predict_paths(paths, batch_size=512):\n    preds = []\n    for i in range(0, len(paths), batch_size):\n        batch_paths = paths[i : i + batch_size]\n        imgs = []\n        for p in batch_paths:\n            im = Image.open(p).convert(\"RGB\").resize((32, 32), Image.BILINEAR)\n            imgs.append(np.asarray(im, dtype=np.float32) / 255.0)\n        x = normalize(tf.constant(np.stack(imgs, axis=0)))\n        prob = model.predict(x, verbose=0)\n        preds.append(np.argmax(prob, axis=1))\n        if (i // batch_size) % 20 == 0:\n            print(f\"Predicted {min(i + batch_size, len(paths))}/{len(paths)}\")\n    return np.concatenate(preds, axis=0)\n\n\nif image_dir is not None:\n    test_paths, test_ids = load_kaggle_test_paths(Path(image_dir))\n    pred_idx = predict_paths(test_paths)\n    pred_labels = [CLASS_NAMES[i] for i in pred_idx]\n\n    submission = pd.DataFrame({\"id\": test_ids, \"label\": pred_labels})\n    submission = submission.sort_values(\"id\")\n    submission.to_csv(SUBMISSION_PATH, index=False)\n    print(\"Wrote\", SUBMISSION_PATH)\n    print(submission.head(10).to_string(index=False))\n    print(submission[\"label\"].value_counts())\nelse:\n    print(\"Skipped submission generation (no test images).\")\n","metadata":{},"outputs":[],"execution_count":null}]}