{"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.13"},"papermill":{"default_parameters":{},"duration":3669.723178,"end_time":"2026-07-18T04:24:26.153738+00:00","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-07-18T03:23:16.430560+00:00","version":"2.7.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"02f7e1ec","cell_type":"code","source":"# 標準ライブラリ: collections=クラス別集計(Counter)、random=乱数シード固定、\n# time=学習時間計測、Path=ファイルパス操作に使用\nimport collections\nimport random\nimport time\nfrom pathlib import Path\n\n# 数値計算とディープラーニングのコアライブラリ\nimport numpy as np\nimport tensorflow as tf","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:46.558502Z","iopub.execute_input":"2026-07-23T12:15:46.558984Z","iopub.status.idle":"2026-07-23T12:15:46.564079Z","shell.execute_reply.started":"2026-07-23T12:15:46.558949Z","shell.execute_reply":"2026-07-23T12:15:46.562962Z"},"papermill":{"duration":19.41094,"end_time":"2026-07-18T03:23:38.679295+00:00","exception":false,"start_time":"2026-07-18T03:23:19.268355+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"cde6ac24","cell_type":"code","source":"SEED = 42  # 乱数シードを固定し、実行のたびに結果がぶれないようにする\nIMAGE_SIZE = (331, 331)  # 192→331に変更。花弁の細かいテクスチャが品種の決め手なので解像度を上げる\n                         # （このコンペのTFRecordは192/224/331/512の4サイズが用意されている。\n                         #   _SUBDIRはIMAGE_SIZEから組み立てられるので、ここを変えるだけで331版のデータを読む）\nNUM_CLASSES = 104  # 花の品種数（分類先クラス数）\nBATCH_SIZE = 32  # グローバルバッチ。MirroredStrategy(2GPU)では1GPUあたり16枚になる\n                 # （331pxのEfficientNetB3でT4のメモリに収まるサイズ）\n\n# --- 学習エポック数（ステージ1: バックボーン凍結で分類ヘッドのみ学習） ---\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)\n\n# --- fine-tuningエポック数（ステージ2: バックボーンを解凍してモデル全体を追加学習） ---\nFINE_TUNE_EPOCHS = 2  # local sanity\nKERNEL_FINE_TUNE_EPOCHS = 12  # 4→12に増量。前回(v3)はエポック9でval_accuracyがまだ上昇中に\n                              # 打ち切っていた（0.7716→0.7856）＝明確なアンダーフィットだったため。\n                              # 伸びが止まった場合はEarlyStopping（学習セル参照）が自動で止める\nFINE_TUNE_LR = 3e-5  # fine-tuning開始時の学習率。ここを起点にコサイン減衰で0に向けて下げていく\n                     # （事前学習済み重みを壊さないよう通常のAdam(1e-3)より2桁小さい値から始める）\nLABEL_SMOOTHING = 0.1  # 正解ラベルを1.0ではなく0.9として学習し、残り0.1を他クラスに配る。\n                       # 104クラス中に似た品種が多いこのコンペでは、モデルが過剰に自信を持つのを\n                       # 防ぎ汎化性能が上がる定番テクニック\n\nCOMPETITION = \"tpu-getting-started\"\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntf.random.set_seed(SEED)","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:46.565673Z","iopub.execute_input":"2026-07-23T12:15:46.566054Z","iopub.status.idle":"2026-07-23T12:15:46.872497Z","shell.execute_reply.started":"2026-07-23T12:15:46.566015Z","shell.execute_reply":"2026-07-23T12:15:46.871436Z"},"papermill":{"duration":0.013345,"end_time":"2026-07-18T03:23:38.695481+00:00","exception":false,"start_time":"2026-07-18T03:23:38.682136+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"9e9af596","cell_type":"code","source":"def _resolve_gcs_path() -> str | None:\n    # Kaggle TPUランタイム専用: TPUはローカルディスクを読めないのでGCS上のデータパスを問い合わせる\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    # Kaggle環境ではない（=ノートブックをローカルPCから実行している）場合のフォールバック。\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# データの在り処を優先順位付きで探す。\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} — \"\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}\")","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:46.887760Z","iopub.execute_input":"2026-07-23T12:15:46.888911Z","iopub.status.idle":"2026-07-23T12:15:47.275633Z","shell.execute_reply.started":"2026-07-23T12:15:46.888342Z","shell.execute_reply":"2026-07-23T12:15:47.274934Z"},"papermill":{"duration":0.162696,"end_time":"2026-07-18T03:23:38.860775+00:00","exception":false,"start_time":"2026-07-18T03:23:38.698079+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"d78660f0","cell_type":"code","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    # JPEGバイト列をデコードし、モデル入力サイズにリサイズする。\n    # 【変更】/255.0の正規化を廃止し、0〜255のfloatのまま返す。\n    # KerasのEfficientNetB3は正規化(Rescaling)をモデル内部に持っており、\n    # 入力は「0〜255の生のピクセル値」が正しい仕様。二重に正規化すると\n    # 事前学習済み重みが想定する入力分布からズレて精度が落ちる。\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)  # 0〜255のまま\n    image = tf.reshape(image, [*IMAGE_SIZE, 3])\n    return image\n\n\ndef augment_image(image, label):\n    # 学習データ専用のオンザフライ・データ拡張。\n    # 1クラス平均120枚程度しかない小規模データセットなので、反転・回転・色調変化で\n    # 疑似的にサンプルを水増しし、過学習を抑えて汎化性能を上げる。\n    # val/testには絶対に適用しない（評価がぶれてしまうため）。\n    image = tf.image.random_flip_left_right(image)\n    image = tf.image.random_flip_up_down(image)\n    image = tf.image.rot90(image, k=tf.random.uniform([], 0, 4, dtype=tf.int32))  # 0/90/180/270度のランダム回転\n    # 【変更】画素値が[0,1]→[0,255]スケールになったので、加算系の明るさ変化と\n    # クリップ範囲も255倍する（乗算系のcontrast/saturationはスケール不変なのでそのまま）\n    image = tf.image.random_brightness(image, max_delta=25.0)  # 旧0.1×255\n    image = tf.image.random_contrast(image, lower=0.8, upper=1.2)\n    image = tf.image.random_saturation(image, lower=0.8, upper=1.2)\n    image = tf.clip_by_value(image, 0.0, 255.0)  # 明るさ/コントラスト変更で範囲をはみ出した値を丸める\n    return image, label\n\n\ndef read_labeled_tfrecord(example):\n    # train/val用: 画像とクラスラベルのペアを1件のTFRecordから復元する。\n    # 【変更】ラベルを整数ではなくone-hotベクトル(104次元)で返す。\n    # ラベルスムージングはCategoricalCrossentropy（one-hot前提）でしか使えないため。\n    parsed = tf.io.parse_single_example(example, LABELED_TFREC_FORMAT)\n    label = tf.one_hot(tf.cast(parsed[\"class\"], tf.int32), NUM_CLASSES)\n    return decode_image(parsed[\"image\"]), label\n\n\ndef read_unlabeled_tfrecord(example):\n    # test用: ラベルが無いのでクラスの代わりに画像ID(文字列)を返す（提出ファイルのid列になる）\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    # train/val/testそれぞれのTFRecordファイル群を指すglobパターンを組み立てる\n    if DATA_SUBDIR:\n        return f\"{DATA_ROOT}/{DATA_SUBDIR}/{split}/*.tfrec\"\n    return f\"{DATA_ROOT}/{split}/*.tfrec\"\n\ndef load_dataset(split: str, labeled: bool):\n    # 指定splitのTFRecordファイルを全部読み込み、パース済みのtf.data.Datasetを返す\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    # 学習用パイプライン: 無限リピート→データ拡張→シャッフル→バッチ化→プリフェッチ。\n    # repeat()しているので、1エポックの区切りはfit()側のsteps_per_epochで明示的に指定する。\n    ds = load_dataset(\"train\", labeled=True)\n    ds = ds.repeat()\n    ds = ds.map(augment_image, num_parallel_calls=tf.data.AUTOTUNE)  # 学習時のみデータ拡張を適用\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    # 検証用パイプライン: 拡張なし・repeatなし（全val画像を1回だけ流す）。\n    # 評価を安定させるため学習時の拡張は一切かけない。\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    # 提出用パイプライン: ラベルの代わりに画像IDを保持する（拡張なし）\n    ds = load_dataset(\"test\", labeled=False)\n    ds = ds.batch(BATCH_SIZE)\n    ds = ds.prefetch(tf.data.AUTOTUNE)\n    return ds","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:47.277123Z","iopub.execute_input":"2026-07-23T12:15:47.277422Z","iopub.status.idle":"2026-07-23T12:15:47.295382Z","shell.execute_reply.started":"2026-07-23T12:15:47.277399Z","shell.execute_reply":"2026-07-23T12:15:47.294468Z"},"papermill":{"duration":0.022451,"end_time":"2026-07-18T03:23:38.886196+00:00","exception":false,"start_time":"2026-07-18T03:23:38.863745+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"b3ce5703","cell_type":"code","source":"def count_data_items(filenames):\n    # 各TFRecordファイル名の末尾に埋め込まれた枚数（例: ...-238.tfrec）を合計する。\n    # ファイルを開いて数えるより高速。\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)\n# repeat()した無限データセットなので、1エポック＝何ステップかをここで自分で決める\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 — 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 — \"\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    )","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:47.296446Z","iopub.execute_input":"2026-07-23T12:15:47.296704Z","iopub.status.idle":"2026-07-23T12:15:47.332630Z","shell.execute_reply.started":"2026-07-23T12:15:47.296682Z","shell.execute_reply":"2026-07-23T12:15:47.332075Z"},"papermill":{"duration":0.053333,"end_time":"2026-07-18T03:23:38.942353+00:00","exception":false,"start_time":"2026-07-18T03:23:38.889020+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"7f1a2c3a","cell_type":"code","source":"# 実行環境に応じた分散学習ストラテジーを選ぶ:\n#   1) Kaggle TPUが使えるならTPUStrategy（最速）\n#   2) TPUが無くてもGPUが複数あるならMirroredStrategyで全GPUを使う\n#      （元のコードはここでdefault=単一デバイスにフォールバックしており、\n#        ログ上検出されていた2枚のTesla T4のうち1枚しか使えていなかった）\n#   3) それ以外（GPU1枚 or CPUのみ）はデフォルトの単一デバイス戦略\ntry:\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}\")\nexcept (ValueError, tf.errors.NotFoundError, Exception):\n    gpus = tf.config.list_physical_devices(\"GPU\")\n    if len(gpus) > 1:\n        strategy = tf.distribute.MirroredStrategy()\n        print(f\"strategy=MirroredStrategy(GPU) replicas={strategy.num_replicas_in_sync}\")\n    else:\n        strategy = tf.distribute.get_strategy()\n        print(f\"strategy=default replicas={strategy.num_replicas_in_sync}\")\n\n# レプリカが複数（TPU or 複数GPU）＝計算資源に余裕があるということなので、\n# ローカルsanity用の少ないエポック数ではなくKaggleインフラ向けのエポック数を使う\nif strategy.num_replicas_in_sync > 1:\n    EPOCHS = KERNEL_EPOCHS\n    FINE_TUNE_EPOCHS = KERNEL_FINE_TUNE_EPOCHS","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:47.333448Z","iopub.execute_input":"2026-07-23T12:15:47.333815Z","iopub.status.idle":"2026-07-23T12:15:47.347448Z","shell.execute_reply.started":"2026-07-23T12:15:47.333792Z","shell.execute_reply":"2026-07-23T12:15:47.346508Z"},"papermill":{"duration":1.83767,"end_time":"2026-07-18T03:23:40.782993+00:00","exception":false,"start_time":"2026-07-18T03:23:38.945323+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"b10f8ddd","cell_type":"code","source":"def build_model():\n    # 【変更】バックボーンをMobileNetV2→EfficientNetB3に変更。\n    # MobileNetV2は「モバイル向けに軽くて速い」が売りで精度は最弱クラス。\n    # EfficientNetB3は精度と計算量のバランスが良く、331px入力とも相性が良い\n    # （B3の設計解像度は300px。192pxのMNV2より大きな画像を活かせる）。\n    base = tf.keras.applications.EfficientNetB3(\n        input_shape=[*IMAGE_SIZE, 3],\n        include_top=False,\n        weights=\"imagenet\",\n    )\n    base.trainable = False  # ステージ1では凍結。後段（学習セルのステージ2）で解凍してfine-tuningする\n    model = tf.keras.Sequential(\n        [\n            base,\n            tf.keras.layers.GlobalAveragePooling2D(),  # 空間方向の特徴マップを1本のベクトルに集約\n            tf.keras.layers.Dense(NUM_CLASSES, activation=\"softmax\"),  # 104クラス分類ヘッド\n        ],\n        name=\"petals_efficientnetb3\",\n    )\n    model.compile(\n        optimizer=tf.keras.optimizers.Adam(),\n        # 【変更】ラベルをone-hot化したのでSparse→CategoricalCrossentropyに。\n        # label_smoothing=0.1で正解に過剰な自信を持たせず、似た品種間の汎化を良くする\n        loss=tf.keras.losses.CategoricalCrossentropy(label_smoothing=LABEL_SMOOTHING),\n        metrics=[tf.keras.metrics.CategoricalAccuracy(name=\"accuracy\")],\n    )\n    return model, base\n\n\nwith strategy.scope():\n    model, base_model = build_model()\n\nmodel.summary()","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:47.348573Z","iopub.execute_input":"2026-07-23T12:15:47.348976Z","iopub.status.idle":"2026-07-23T12:15:51.384365Z","shell.execute_reply.started":"2026-07-23T12:15:47.348951Z","shell.execute_reply":"2026-07-23T12:15:51.383721Z"},"papermill":{"duration":6.673832,"end_time":"2026-07-18T03:23:47.459737+00:00","exception":false,"start_time":"2026-07-18T03:23:40.785905+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"fc21a5b4","cell_type":"code","source":"# --- ステージ1: 分類ヘッドのみ学習（バックボーンは凍結） ---\n# ランダム初期化されたDense層をまず安定させる。バックボーンが凍結されているので\n# 大きめの学習率（Adamのデフォルト）でも事前学習済み重みは壊れない。\nt0 = 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)\nhead_train_seconds = time.time() - t0\nprint(f\"head_train_seconds={head_train_seconds:.1f}\")\n\n# --- ステージ2: バックボーンを解凍してend-to-endでfine-tuning ---\n# ヘッドがある程度収束した後にEfficientNetB3全体を解凍し、事前学習済み重みを\n# 破壊しないよう小さい学習率から追加学習する。\n# trainableの変更とrecompileはstrategy.scope()の中で行う必要がある。\nwith strategy.scope():\n    base_model.trainable = True\n\n    # 【追加】BatchNormalization層だけは凍結したままにする。\n    # EfficientNetのBN層が持つ「ImageNet全体の統計量」を、1クラス120枚程度の\n    # 小規模データの統計で上書きすると学習が不安定になる（Keras転移学習の定石）。\n    for layer in base_model.layers:\n        if isinstance(layer, tf.keras.layers.BatchNormalization):\n            layer.trainable = False\n\n    # 【追加】学習率のコサイン減衰スケジュール。\n    # FINE_TUNE_LR(3e-5)から始めて、fine-tuning全ステップをかけて滑らかに0へ下げる。\n    # 序盤は大きめの率で速く適応し、終盤は小さい率で細部を詰める、を自動でやってくれる\n    lr_schedule = tf.keras.optimizers.schedules.CosineDecay(\n        initial_learning_rate=FINE_TUNE_LR,\n        decay_steps=STEPS_PER_EPOCH * FINE_TUNE_EPOCHS,\n    )\n    model.compile(\n        optimizer=tf.keras.optimizers.Adam(lr_schedule),\n        loss=tf.keras.losses.CategoricalCrossentropy(label_smoothing=LABEL_SMOOTHING),\n        metrics=[tf.keras.metrics.CategoricalAccuracy(name=\"accuracy\")],\n    )\n\n# 【追加】EarlyStopping: val_accuracyが3エポック改善しなかったら打ち切り、\n# restore_best_weights=Trueで「一番良かった時点の重み」に巻き戻す。\n# エポックを12に増やした分の保険（過学習が始まったら自動で最良点に戻る）\nearly_stop = tf.keras.callbacks.EarlyStopping(\n    monitor=\"val_accuracy\",\n    patience=3,\n    restore_best_weights=True,\n    verbose=1,\n)\n\nt0 = time.time()\nmodel.fit(\n    get_training_dataset(),\n    steps_per_epoch=STEPS_PER_EPOCH,\n    epochs=EPOCHS + FINE_TUNE_EPOCHS,\n    initial_epoch=EPOCHS,  # ステージ1の続きのエポック番号としてログ表示させる\n    validation_data=get_validation_dataset(),\n    callbacks=[early_stop],\n    verbose=2,\n)\nfinetune_seconds = time.time() - t0\ntrain_seconds = head_train_seconds + finetune_seconds\nprint(f\"finetune_seconds={finetune_seconds:.1f} train_seconds={train_seconds:.1f}\")","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:15:51.385493Z","iopub.execute_input":"2026-07-23T12:15:51.385816Z","iopub.status.idle":"2026-07-23T12:24:33.506297Z","shell.execute_reply.started":"2026-07-23T12:15:51.385780Z","shell.execute_reply":"2026-07-23T12:24:33.505075Z"},"papermill":{"duration":3480.71123,"end_time":"2026-07-18T04:21:48.174771+00:00","exception":false,"start_time":"2026-07-18T03:23:47.463541+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"f30abb80","cell_type":"code","source":"# fine-tuning後の最終モデルをvalidation set全体で評価する\nval_loss, val_acc = model.evaluate(get_validation_dataset(), verbose=0)\nprint(f\"holdout_accuracy={val_acc:.4f} val_loss={val_loss:.4f}\")","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:55:29.510017Z","iopub.execute_input":"2026-07-23T12:55:29.510508Z","iopub.status.idle":"2026-07-23T12:55:59.034101Z","shell.execute_reply.started":"2026-07-23T12:55:29.510476Z","shell.execute_reply":"2026-07-23T12:55:59.033060Z"},"papermill":{"duration":21.569572,"end_time":"2026-07-18T04:22:09.750480+00:00","exception":false,"start_time":"2026-07-18T04:21:48.180908+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"dfb60510","cell_type":"code","source":"# クラス別のTP/FP/FNを手動集計し、macro F1（全クラスのF1を単純平均）を計算する。\n# 全体accuracyだけだと、画像数が多いクラスに引っ張られて少数クラスの不振が\n# 見えにくいため、104クラスを均等に扱うmacro F1も併せて確認する。\nper_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().argmax(axis=1)  # one-hotベクトル→整数ラベルに戻して集計する\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        # そのクラスへの予測・正解がどちらも無い＝評価不能なのでF1=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}\")","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:56:13.325287Z","iopub.execute_input":"2026-07-23T12:56:13.325610Z","iopub.status.idle":"2026-07-23T12:57:22.063948Z","shell.execute_reply.started":"2026-07-23T12:56:13.325581Z","shell.execute_reply":"2026-07-23T12:57:22.063189Z"},"papermill":{"duration":79.339947,"end_time":"2026-07-18T04:23:29.096462+00:00","exception":false,"start_time":"2026-07-18T04:22:09.756515+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"e3c8188d","cell_type":"code","source":"# testデータがある場合のみ提出用CSV(submission.csv)を作成する。\n# ローカルslice実行時はtest splitが無いことがあるので、その場合はスキップする\n# （Kaggleカーネル上ではtest splitが必ずあるので実際に生成される）。\ntry:\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\"  # Kaggle上はここに置くと自動的に提出候補として認識される\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 — skipping submission.csv (Kaggle kernel will produce it)\"\n    )","metadata":{"execution":{"iopub.status.busy":"2026-07-23T12:58:08.210323Z","iopub.execute_input":"2026-07-23T12:58:08.211246Z","iopub.status.idle":"2026-07-23T12:58:59.320018Z","shell.execute_reply.started":"2026-07-23T12:58:08.211214Z","shell.execute_reply":"2026-07-23T12:58:59.319271Z"},"papermill":{"duration":52.831328,"end_time":"2026-07-18T04:24:21.933863+00:00","exception":false,"start_time":"2026-07-18T04:23:29.102535+00:00","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null}]}