{"cells": [{"cell_type": "markdown", "id": "70e0625ec44b", "metadata": {}, "source": "# Petals to the Metal \u2014 baseline (transfer learning)\n\nM10-T002 baseline for the\n[Petals to the Metal \u2014 Flower Classification on TPU](https://www.kaggle.com/competitions/tpu-getting-started)\ncompetition.\n\n- **Backbone:** `MobileNetV2` pretrained on ImageNet (small, fast \u2014 sanity baseline, not a leaderboard chaser).\n- **Image size:** 192x192 (smallest published variant).\n- **Optimizer / epochs:** Adam (default LR), 3 epochs for local sanity (TPU kernel may train longer; see `EPOCHS` constant).\n- **Seed:** 42.\n- **Split:** Kaggle ships `train/` and `val/` TFRecord splits separately \u2014 we use them as-is (holdout accuracy on `val/`).\n\nWhen executed on Kaggle's kernel runner, this notebook reads TFRecords from the GCS path exposed by\n`kaggle_datasets.KaggleDatasets().get_gcs_path('tpu-getting-started')`. When executed locally\n(e.g. via `jupyter nbconvert --to notebook --execute`), it falls back to the local slice at\n`data/tpu-getting-started/local-slice/{train,val}/*.tfrec` that M10-T002 staged for sanity.\n\n**Defense-in-depth:** this notebook does NOT call `kaggle kernels push` from any cell. Kernel pushing\nhappens via `kca kaggle push` in M10-T004, never from inside the notebook itself."}, {"cell_type": "code", "execution_count": null, "id": "eccb8dcd5363", "metadata": {}, "outputs": [], "source": "import collections\nimport random\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport tensorflow as tf"}, {"cell_type": "markdown", "id": "028ad9cdbc4d", "metadata": {}, "source": "## Configuration"}, {"cell_type": "code", "execution_count": null, "id": "5cbfbd5f69e3", "metadata": {}, "outputs": [], "source": "SEED = 42\nIMAGE_SIZE = (192, 192)\nNUM_CLASSES = 104\nBATCH_SIZE = 32  # small for local CPU; Kaggle kernel can override\nEPOCHS = 3  # local sanity: 3; Kaggle kernel runner may bump (see KERNEL_EPOCHS)\nKERNEL_EPOCHS = 5  # used when running on Kaggle infra (see strategy block below)\nCOMPETITION = \"tpu-getting-started\"\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntf.random.set_seed(SEED)"}, {"cell_type": "markdown", "id": "8741c6af115b", "metadata": {}, "source": "## Resolve data paths\n\nTry Kaggle's GCS-backed dataset path first (kernel runner). Fall back to the local M10-T002 slice."}, {"cell_type": "code", "execution_count": null, "id": "620cded72e5f", "metadata": {}, "outputs": [], "source": "def _resolve_gcs_path() -> str | None:\n    try:\n        from kaggle_datasets import KaggleDatasets  # type: ignore\n\n        return KaggleDatasets().get_gcs_path(COMPETITION)\n    except Exception as exc:\n        print(f\"[data-resolve] kaggle_datasets GCS path unavailable: {exc!r}\")\n        return None\n\n\ndef _resolve_kaggle_input_path() -> str | None:\n    # Try the direct competition mount first, then the /competitions/<slug>/ layout\n    # Kaggle uses for \"official\" Getting Started competitions (v3 diagnostic showed\n    # /kaggle/input had only ['competitions'], not the slug directly).\n    candidates = [\n        Path(\"/kaggle/input\") / COMPETITION,\n        Path(\"/kaggle/input/competitions\") / COMPETITION,\n    ]\n    for p in candidates:\n        if p.exists():\n            print(f\"[data-resolve] /kaggle/input mount found at {p}\")\n            return str(p)\n    # Diagnostic: list what IS under /kaggle/input/ so a future failure shows\n    # the real layout instead of an opaque \"not found\".\n    root = Path(\"/kaggle/input\")\n    if root.exists():\n        try:\n            print(\n                f\"[data-resolve] /kaggle/input contents: {sorted(p.name for p in root.iterdir())}\"\n            )\n            comp_root = root / \"competitions\"\n            if comp_root.exists():\n                print(\n                    f\"[data-resolve] /kaggle/input/competitions contents: \"\n                    f\"{sorted(p.name for p in comp_root.iterdir())}\"\n                )\n        except Exception as exc:\n            print(f\"[data-resolve] /kaggle/input listdir failed: {exc!r}\")\n    else:\n        print(\"[data-resolve] /kaggle/input does not exist\")\n    return None\n\n\ndef _resolve_local_slice() -> str | None:\n    # When this notebook is executed locally from the repo root.\n    for candidate in [\n        Path.cwd() / \"data\" / COMPETITION / \"local-slice\",\n        Path(\"/home/harry/test/tt_pangu/.claude/worktrees/m10-petals-to-the-metal/data\")\n        / COMPETITION\n        / \"local-slice\",\n    ]:\n        if candidate.exists():\n            return str(candidate)\n    return None\n\n\ndef _has_tfrecord_subdir(root: str, subdir: str) -> bool:\n    # Validate that a resolver-returned `root` actually contains the expected\n    # TFRecord layout. v4 errored because GCS resolver returned a path that\n    # didn't contain the shards (Kaggle TPU shortcut to the wrong mount).\n    try:\n        check = f\"{root}/{subdir}/train\"\n        return tf.io.gfile.exists(check)\n    except Exception:\n        return False\n\n\n_SUBDIR = f\"tfrecords-jpeg-{IMAGE_SIZE[0]}x{IMAGE_SIZE[1]}\"\n\n# Order matters: prefer the explicit /kaggle/input/competitions/<slug>/ mount\n# (validated by _has_tfrecord_subdir) over the GCS shortcut, then fall back to\n# the GCS path, then a local slice for off-Kaggle execution.\nDATA_ROOT, DATA_SUBDIR, RUNTIME = None, None, None\nfor label, candidate in (\n    (\"kaggle-input\", _resolve_kaggle_input_path()),\n    (\"kaggle-tpu-gcs\", _resolve_gcs_path()),\n):\n    if candidate and _has_tfrecord_subdir(candidate, _SUBDIR):\n        DATA_ROOT, DATA_SUBDIR, RUNTIME = candidate, _SUBDIR, label\n        print(f\"[data-resolve] accepted {label} root={candidate}\")\n        break\n    elif candidate:\n        print(\n            f\"[data-resolve] rejected {label} root={candidate} \u2014 \"\n            f\"{_SUBDIR}/train not present\"\n        )\n\nif DATA_ROOT is None:\n    local = _resolve_local_slice()\n    if local:\n        DATA_ROOT, DATA_SUBDIR, RUNTIME = local, \"\", \"local-slice\"\n\nif DATA_ROOT is None:\n    raise RuntimeError(\n        \"No data source resolved: no Kaggle mount contains \"\n        f\"{_SUBDIR}/train, and no local slice is present. \"\n        \"Run scripts/m10_t002_download_slice.py to stage a local slice.\"\n    )\n\nprint(f\"runtime={RUNTIME} data_root={DATA_ROOT} subdir={DATA_SUBDIR!r}\")"}, {"cell_type": "markdown", "id": "3b191406259e", "metadata": {}, "source": "## TFRecord decoding"}, {"cell_type": "code", "execution_count": null, "id": "1832280a614d", "metadata": {}, "outputs": [], "source": "LABELED_TFREC_FORMAT = {\n    \"image\": tf.io.FixedLenFeature([], tf.string),\n    \"class\": tf.io.FixedLenFeature([], tf.int64),\n}\n\nUNLABELED_TFREC_FORMAT = {\n    \"image\": tf.io.FixedLenFeature([], tf.string),\n    \"id\": tf.io.FixedLenFeature([], tf.string),\n}\n\n\ndef decode_image(image_bytes):\n    image = tf.image.decode_jpeg(image_bytes, channels=3)\n    image = tf.image.resize(image, IMAGE_SIZE)\n    image = tf.cast(image, tf.float32) / 255.0\n    image = tf.reshape(image, [*IMAGE_SIZE, 3])\n    return image\n\n\ndef read_labeled_tfrecord(example):\n    parsed = tf.io.parse_single_example(example, LABELED_TFREC_FORMAT)\n    return decode_image(parsed[\"image\"]), tf.cast(parsed[\"class\"], tf.int32)\n\n\ndef read_unlabeled_tfrecord(example):\n    parsed = tf.io.parse_single_example(example, UNLABELED_TFREC_FORMAT)\n    return decode_image(parsed[\"image\"]), parsed[\"id\"]\n\n\ndef _split_glob(split: str) -> str:\n    if DATA_SUBDIR:\n        return f\"{DATA_ROOT}/{DATA_SUBDIR}/{split}/*.tfrec\"\n    return f\"{DATA_ROOT}/{split}/*.tfrec\"\n\n\ndef load_dataset(split: str, labeled: bool):\n    files = tf.io.gfile.glob(_split_glob(split))\n    if not files:\n        raise RuntimeError(\n            f\"No TFRecords found for split={split} glob={_split_glob(split)}\"\n        )\n    ds = tf.data.TFRecordDataset(files, num_parallel_reads=tf.data.AUTOTUNE)\n    ds = ds.with_options(tf.data.Options())\n    ds = ds.map(\n        read_labeled_tfrecord if labeled else read_unlabeled_tfrecord,\n        num_parallel_calls=tf.data.AUTOTUNE,\n    )\n    return ds\n\n\ndef get_training_dataset():\n    ds = load_dataset(\"train\", labeled=True)\n    ds = ds.repeat()\n    ds = ds.shuffle(2048, seed=SEED)\n    ds = ds.batch(BATCH_SIZE, drop_remainder=True)\n    ds = ds.prefetch(tf.data.AUTOTUNE)\n    return ds\n\n\ndef get_validation_dataset():\n    ds = load_dataset(\"val\", labeled=True)\n    ds = ds.batch(BATCH_SIZE)\n    ds = ds.prefetch(tf.data.AUTOTUNE)\n    return ds\n\n\ndef get_test_dataset():\n    ds = load_dataset(\"test\", labeled=False)\n    ds = ds.batch(BATCH_SIZE)\n    ds = ds.prefetch(tf.data.AUTOTUNE)\n    return ds"}, {"cell_type": "markdown", "id": "8de9c034051c", "metadata": {}, "source": "## Dataset sizing\n\nPetals to the Metal's TFRecord filenames embed the per-shard count (e.g. `00-192x192-798.tfrec` = 798 examples).\nWe extract that to know `steps_per_epoch` without iterating the whole dataset."}, {"cell_type": "code", "execution_count": null, "id": "6fd0a1718aa1", "metadata": {}, "outputs": [], "source": "def count_data_items(filenames):\n    import re\n\n    total = 0\n    for fn in filenames:\n        m = re.search(r\"-(\\d+)\\.tfrec$\", fn)\n        if m:\n            total += int(m.group(1))\n    return total\n\n\ntrain_files = tf.io.gfile.glob(_split_glob(\"train\"))\nval_files = tf.io.gfile.glob(_split_glob(\"val\"))\n\nNUM_TRAINING_IMAGES = count_data_items(train_files)\nNUM_VALIDATION_IMAGES = count_data_items(val_files)\nSTEPS_PER_EPOCH = max(1, NUM_TRAINING_IMAGES // BATCH_SIZE)\n\nprint(\n    f\"train shards={len(train_files)} train_images={NUM_TRAINING_IMAGES} \"\n    f\"val shards={len(val_files)} val_images={NUM_VALIDATION_IMAGES} \"\n    f\"steps_per_epoch={STEPS_PER_EPOCH}\"\n)\n\n# Fail loudly if the resolver pointed at an empty path \u2014 v3 silently trained on\n# zero shards because we trusted the resolver's word. Catch that here.\nif len(train_files) == 0 or len(val_files) == 0:\n    raise RuntimeError(\n        f\"Zero TFRecords resolved at {DATA_ROOT}/{DATA_SUBDIR or '<root>'}/{{train,val}}/*.tfrec \u2014 \"\n        f\"train_files={len(train_files)} val_files={len(val_files)}. \"\n        f\"The resolver returned a path that does not contain the expected shards; \"\n        f\"investigate the /kaggle/input layout (see earlier [data-resolve] prints) \"\n        f\"before pushing again.\"\n    )"}, {"cell_type": "markdown", "id": "07e61bd7808c", "metadata": {}, "source": "## Strategy (TPU vs GPU/CPU)"}, {"cell_type": "code", "execution_count": null, "id": "81a2d0086cd4", "metadata": {}, "outputs": [], "source": "try:\n    tpu = tf.distribute.cluster_resolver.TPUClusterResolver.connect()\n    strategy = tf.distribute.TPUStrategy(tpu)\n    print(f\"strategy=TPU replicas={strategy.num_replicas_in_sync}\")\n    EPOCHS = KERNEL_EPOCHS\nexcept (ValueError, tf.errors.NotFoundError, Exception):\n    strategy = tf.distribute.get_strategy()\n    print(f\"strategy=default replicas={strategy.num_replicas_in_sync}\")"}, {"cell_type": "markdown", "id": "d175dcf5b679", "metadata": {}, "source": "## Model"}, {"cell_type": "code", "execution_count": null, "id": "c77d12f1abcd", "metadata": {}, "outputs": [], "source": "def build_model():\n    base = tf.keras.applications.MobileNetV2(\n        input_shape=[*IMAGE_SIZE, 3],\n        include_top=False,\n        weights=\"imagenet\",\n    )\n    base.trainable = False\n    model = tf.keras.Sequential(\n        [\n            base,\n            tf.keras.layers.GlobalAveragePooling2D(),\n            tf.keras.layers.Dense(NUM_CLASSES, activation=\"softmax\"),\n        ],\n        name=\"petals_baseline_mobilenetv2\",\n    )\n    model.compile(\n        optimizer=tf.keras.optimizers.Adam(),\n        loss=tf.keras.losses.SparseCategoricalCrossentropy(),\n        metrics=[tf.keras.metrics.SparseCategoricalAccuracy(name=\"accuracy\")],\n    )\n    return model\n\n\nwith strategy.scope():\n    model = build_model()\n\nmodel.summary()"}, {"cell_type": "markdown", "id": "54b38e82f6b4", "metadata": {}, "source": "## Train"}, {"cell_type": "code", "execution_count": null, "id": "0c0a735bb89b", "metadata": {}, "outputs": [], "source": "t0 = time.time()\nmodel.fit(\n    get_training_dataset(),\n    steps_per_epoch=STEPS_PER_EPOCH,\n    epochs=EPOCHS,\n    validation_data=get_validation_dataset(),\n    verbose=2,\n)\ntrain_seconds = time.time() - t0\nprint(f\"train_seconds={train_seconds:.1f}\")"}, {"cell_type": "markdown", "id": "9cec452ad928", "metadata": {}, "source": "## Holdout accuracy"}, {"cell_type": "code", "execution_count": null, "id": "5bb8e725183b", "metadata": {}, "outputs": [], "source": "val_loss, val_acc = model.evaluate(get_validation_dataset(), verbose=0)\nprint(f\"holdout_accuracy={val_acc:.4f} val_loss={val_loss:.4f}\")"}, {"cell_type": "markdown", "id": "b34552ed5b7b", "metadata": {}, "source": "## Macro F1 on holdout\n\nPetals to the Metal scores submissions on macro F1; we record both holdout accuracy AND macro F1 locally."}, {"cell_type": "code", "execution_count": null, "id": "35e3d40299c3", "metadata": {}, "outputs": [], "source": "per_class_tp = collections.Counter()\nper_class_fp = collections.Counter()\nper_class_fn = collections.Counter()\n\nfor batch_images, batch_labels in get_validation_dataset():\n    preds = model.predict(batch_images, verbose=0).argmax(axis=1)\n    labels = batch_labels.numpy()\n    for y, p in zip(labels, preds):\n        if y == p:\n            per_class_tp[int(y)] += 1\n        else:\n            per_class_fp[int(p)] += 1\n            per_class_fn[int(y)] += 1\n\nf1s = []\nfor cls in range(NUM_CLASSES):\n    tp = per_class_tp[cls]\n    fp = per_class_fp[cls]\n    fn = per_class_fn[cls]\n    if tp + fp == 0 or tp + fn == 0:\n        f1s.append(0.0)\n        continue\n    precision = tp / (tp + fp)\n    recall = tp / (tp + fn)\n    if precision + recall == 0:\n        f1s.append(0.0)\n    else:\n        f1s.append(2 * precision * recall / (precision + recall))\n\nmacro_f1 = float(np.mean(f1s))\nprint(f\"macro_f1={macro_f1:.4f}\")"}, {"cell_type": "markdown", "id": "27218bb80bbc", "metadata": {}, "source": "## Test-set inference + submission.csv\n\nOn Kaggle's kernel runner this writes `/kaggle/working/submission.csv` (the kernel push artifact).\nLocally we skip test-set inference unless the local slice contains a `test/` directory (it does not by default)."}, {"cell_type": "code", "execution_count": null, "id": "33de82a5b587", "metadata": {}, "outputs": [], "source": "try:\n    test_files = tf.io.gfile.glob(_split_glob(\"test\"))\nexcept Exception:\n    test_files = []\n\nif test_files:\n    test_ds = get_test_dataset()\n    test_images_ds = test_ds.map(lambda image, idnum: image)\n    test_ids_ds = test_ds.map(lambda image, idnum: idnum).unbatch()\n    probs = model.predict(test_images_ds, verbose=0)\n    preds = probs.argmax(axis=1)\n    ids = [b.decode(\"utf-8\") for b in next(iter(test_ids_ds.batch(1_000_000))).numpy()]\n    out_path = \"submission.csv\"\n    if Path(\"/kaggle/working\").exists():\n        out_path = \"/kaggle/working/submission.csv\"\n    with open(out_path, \"w\", encoding=\"utf-8\") as f:\n        f.write(\"id,label\\n\")\n        for i, p in zip(ids, preds):\n            f.write(f\"{i},{int(p)}\\n\")\n    print(f\"submission written to {out_path} (n={len(ids)})\")\nelse:\n    print(\n        \"no test/ split available locally \u2014 skipping submission.csv (Kaggle kernel will produce it)\"\n    )"}, {"cell_type": "markdown", "id": "74c8a50f0f67", "metadata": {}, "source": "## Done.\n\nLocal sanity metrics are surfaced as the last two stdout lines (`holdout_accuracy=` and `macro_f1=`),\nwhich `scripts/m10_t002_local_train.py` parses to record into `experiment_runs` via `kca run record-metric`."}], "metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, "language_info": {"name": "python"}}, "nbformat": 4, "nbformat_minor": 5}