{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"accelerator":"GPU","colab":{"name":"cats_vs_dogs_kaggle.ipynb","provenance":[],"gpuType":"T4","toc_visible":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Cats vs Dogs — классификация изображений\n\n**Контест:** [Dogs vs. Cats Redux: Kernels Edition](https://www.kaggle.com/c/dogs-vs-cats-redux-kernels-edition)\n**Данные:** 25 000 фотографий кошек и собак с метками (train) и 12 500 без меток (test).\n**Задача:** для каждой тестовой картинки выдать **вероятность того, что на ней собака**.\n**Метрика:** Log Loss, нужно получить **меньше 0.3**.\n\n---\n\n### Почему метрика — вероятность, а не «кошка/собака»\n\nLog loss считается по формуле\n\n$$\\text{LogLoss} = -\\frac{1}{N}\\sum_i \\big[\\, y_i \\ln p_i + (1-y_i)\\ln(1-p_i) \\big]$$\n\nгде $y_i$ — правильный ответ (0 или 1), $p_i$ — наша вероятность собаки. Проще говоря,\nметрика штрафует не за сам факт ошибки, а за **уверенность**, с которой мы ошиблись:\n\n| что сделала модель | штраф за эту картинку |\n|---|---|\n| угадала, ответив 0.99 | 0.01 — почти ноль |\n| ответила 0.5 («не знаю») | 0.69 |\n| ошиблась, ответив 0.99 | **4.6** — как 460 правильных ответов |\n\nОтсюда вся стратегия: нужна и высокая точность, и аккуратность в крайних вероятностях.\nВторое мы разберём отдельно в разделе 8 (клиппинг).\n\n### Идея решения: transfer learning\n\nОбучать свёрточную сеть с нуля на 25 000 картинок долго и не нужно. Есть сети, уже обученные\nна ImageNet (1.2 млн изображений, 1000 классов); их свёрточная часть умеет выделять\nуниверсальные признаки — края, текстуры, формы, части объектов. Кошки и собаки в ImageNet\nтоже были, так что признаки для нашей задачи почти готовые.\n\nСхема такая:\n\n1. Берём готовую сеть **MobileNetV2** без последнего классифицирующего слоя («без головы»).\n2. **Замораживаем** её веса и учим поверх один свой слой на 1 нейрон — это и есть *transfer\n   learning*. Здесь учится очень мало параметров, поэтому шаг обучения большой (1e-3).\n3. Потом **размораживаем верхние блоки** и дообучаем их крошечным шагом (1e-5) — это\n   *fine-tuning*: признаки подстраиваются именно под кошек и собак.\n\nПочему именно так, а не сразу всё вместе: у необученной головы на старте случайные веса,\nи большие градиенты от неё разрушили бы хорошие предобученные признаки.\n\n### Как устроен ноутбук\n\n| Раздел | Что делаем | Зачем |\n|---|---|---|\n| 0 | настройки сессии | включить ускоритель и интернет |\n| 1 | распаковка данных | в `/kaggle/input` картинки лежат архивами |\n| 2–3 | конфигурация, разбиение train/val | отложить честную выборку для проверки |\n| 4 | пайплайн `tf.data` | быстро подавать картинки в GPU, не забивая память |\n| 5 | модель | MobileNetV2 + своя голова + аугментации |\n| 6–7 | обучение в два этапа | сначала голова, потом fine-tuning |\n| 8 | оценка, клиппинг, TTA | выжать из log loss последние сотые |\n| 9–10 | `submission.csv` и отправка | получить скор на лидерборде |\n\n","metadata":{}},{"cell_type":"markdown","source":"## 0. Настройки сессии и проверка GPU\n\nПеред первым запуском нужно выставить две вещи в правой панели **Settings** (или в меню\n*Settings* сверху):\n\n1. **Accelerator → GPU.** Подойдёт и `GPU T4 x2`, и `P100` — TensorFlow по умолчанию займёт\n   одну карту. Без ускорителя обучение растянется на часы. Бесплатный лимит — 30 GPU-часов\n   в неделю, этот прогон съест меньше часа.\n2. **Internet → On.** Обязательно: модель скачивает предобученные веса ImageNet, и без\n   интернета ячейка с моделью упадёт. Переключатель доступен на аккаунтах с подтверждённым\n   телефоном (*Settings → Phone Verification*).\n\nПосле смены любого из этих параметров сессия перезапускается, и ячейки нужно прогнать заново.\n\nЯчейка ниже проверяет, что ускоритель на месте: должна напечататься модель карты и непустой\nсписок устройств. `GPU: []` и `nvidia-smi: command not found` означают, что Accelerator всё ещё\nвыключен — дальше идти бессмысленно.","metadata":{}},{"cell_type":"code","source":"!nvidia-smi -L\nimport tensorflow as tf\nprint('TensorFlow:', tf.__version__)\nprint('GPU:', tf.config.list_physical_devices('GPU'))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:00.900720Z","iopub.execute_input":"2026-09-19T08:51:00.901136Z","iopub.status.idle":"2026-09-19T08:51:01.033662Z","shell.execute_reply.started":"2026-09-19T08:51:00.901103Z","shell.execute_reply":"2026-09-19T08:51:01.032716Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Данные: они уже подключены\n\nГлавное отличие ноутбука на Kaggle от Colab — скачивать ничего не нужно и токен `kaggle.json`\nне требуется. Датасет контеста примонтирован к сессии: он виден в правой панели **Input**\nи лежит в `/kaggle/input/dogs-vs-cats-redux-kernels-edition/`. Если его там нет —\nправая панель → **Add Input** → вкладка *Competitions* → *Dogs vs. Cats Redux*.\n\nВнутри лежат `train.zip`, `test.zip` и `sample_submission.csv`, то есть картинки всё-таки\nупакованы и их надо распаковать. В самом `/kaggle/input` писать нельзя, он только для чтения.\nВ Kaggle-ноутбуке три папки, и важно не перепутать:\n\n| Папка | Что это | Пишем сюда? |\n|---|---|---|\n| `/kaggle/input` | подключённые датасеты | нет, только чтение |\n| `/kaggle/working` | то, что сохранится в версию ноутбука | только `submission.csv` |\n| `/kaggle/temp` | временная папка на время сессии | да, сюда распакуем картинки |\n\nПочему картинки не в `/kaggle/working`: всё оттуда попадает в Output версии, и 37 тысяч файлов\nсохранялись бы очень долго. В `/kaggle/temp` они живут до конца сессии — ровно сколько нужно.\n\nРаспаковка занимает минуту-полторы; повторный запуск ячейки ничего не переделывает.\n\n⚠️ Если ячейка пишет, что `/kaggle/input` пуст или в нём нет нужной папки, а в панели Input\nдатасет виден — **перезапустите сессию** (*Run → Restart session*). Данные, подключённые\nк уже запущенной сессии, монтируются только после перезапуска.","metadata":{}},{"cell_type":"code","source":"import os, glob, zipfile\n\nBASE = '/kaggle/input'\nprint('в /kaggle/input:', sorted(os.listdir(BASE)) if os.path.isdir(BASE) else 'папки нет вообще')\n\n\ndef find_data_dir(base=BASE, max_depth=3):\n    \"\"\"Ищем папку с данными по содержимому: путь и глубина вложенности у Kaggle меняются.\"\"\"\n    queue = [(base, 0)]\n    while queue:\n        path, depth = queue.pop(0)\n        try:\n            names = set(os.listdir(path))\n        except OSError:\n            continue\n        if {'train.zip', 'test.zip'} <= names or {'train', 'test'} <= names:\n            return path\n        if depth < max_depth:\n            queue += [(os.path.join(path, n), depth + 1)\n                      for n in sorted(names) if os.path.isdir(os.path.join(path, n))]\n    return None\n\n\nINPUT_DIR = find_data_dir()\n\nif INPUT_DIR is None:                      # покажем, что вообще примонтировано\n    for root, dirs, files in os.walk(BASE):\n        if root[len(BASE):].count(os.sep) < 2:\n            print(root, '->', sorted(dirs)[:5], sorted(files)[:5])\nassert INPUT_DIR, ('не нашли данные контеста в /kaggle/input (что там есть — напечатано выше). '\n                   'Подключите их: панель Input → Add Input → вкладка Competitions → '\n                   'Dogs vs. Cats Redux, а затем перезапустите сессию (Run → Restart session)')\nprint('данные контеста:', INPUT_DIR)\nprint('внутри:', sorted(os.listdir(INPUT_DIR))[:6])\n\nif os.path.exists(os.path.join(INPUT_DIR, 'train.zip')):\n    DATA_ROOT = '/kaggle/temp/data'                 # архивы: распаковываем во временную папку\n    os.makedirs(DATA_ROOT, exist_ok=True)\n    for name in ['train.zip', 'test.zip']:\n        target = os.path.join(DATA_ROOT, name[:-4])\n        if os.path.isdir(target) and os.listdir(target):\n            print(f'{name}: уже распакован, пропускаю')\n            continue\n        print(f'{name}: распаковываю...')\n        with zipfile.ZipFile(os.path.join(INPUT_DIR, name)) as z:\n            z.extractall(DATA_ROOT)\nelse:\n    DATA_ROOT = INPUT_DIR                           # уже распаковано — читаем прямо из Input\n\nprint()\nprint('DATA_ROOT =', DATA_ROOT)\nprint('train:', len(glob.glob(f'{DATA_ROOT}/train/*.jpg')))   # ожидаем 25000\nprint('test :', len(glob.glob(f'{DATA_ROOT}/test/*.jpg')))    # ожидаем 12500","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:01.035626Z","iopub.execute_input":"2026-09-19T08:51:01.035916Z","iopub.status.idle":"2026-09-19T08:51:12.448098Z","shell.execute_reply.started":"2026-09-19T08:51:01.035884Z","shell.execute_reply":"2026-09-19T08:51:12.447126Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Импорты и конфигурация\n\nВсе настройки собраны в одном месте — так их удобно менять, не выискивая по ячейкам.\nЧто здесь важно понимать:\n\n* **`SEED`** — фиксируем генераторы случайных чисел (Python, NumPy, TensorFlow), чтобы\n  разбиение на train/val и инициализация весов повторялись от запуска к запуску. Без этого\n  нельзя честно сравнить две версии решения: непонятно, изменилось качество от правки или от случайности.\n* **`IMG_SIZE = 224`** — размер, на котором обучался MobileNetV2 на ImageNet. Можно меньше\n  (быстрее, но грубее), больше смысла не имеет.\n* **`BATCH_SIZE`** — сколько картинок идёт в GPU за раз. Упирается в видеопамять: MobileNetV2\n  лёгкий, влезает 64; VGG16 тяжёлый, ему ставим 32.\n* **`EPOCHS_HEAD` / `EPOCHS_FT`** — длительность двух этапов обучения (голова / fine-tuning).\n* **`USE_CACHE`** — сохранять ли распакованные картинки на диск (подробнее в разделе 4).\n* **`PREPROCESS`** — функция нормализации. У каждой предобученной сети своя: MobileNetV2 ждёт\n  значения в диапазоне [−1, 1], VGG16 — вычитание средних цветов ImageNet в порядке BGR.\n  Подать «не ту» нормализацию — типичная ошибка, качество молча просядет.","metadata":{}},{"cell_type":"code","source":"import os, glob, random, shutil\nimport numpy as np\nimport pandas as pd\nimport tensorflow as tf\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import log_loss, accuracy_score\n\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); tf.keras.utils.set_random_seed(SEED)\n\nDATA_DIR  = globals().get('DATA_ROOT', '/kaggle/temp/data')   # путь определился в разделе 1\nTRAIN_DIR = os.path.join(DATA_DIR, 'train')\nTEST_DIR  = os.path.join(DATA_DIR, 'test')\nCACHE_DIR = '/kaggle/temp/cache'     # кэш tf.data — тоже во временную папку\nSUB_PATH  = '/kaggle/working/submission.csv'   # а вот сабмит должен попасть в Output версии\n\nBACKBONE    = 'MobileNetV2'   # 'MobileNetV2' или 'VGG16'\nIMG_SIZE    = 224\nBATCH_SIZE  = 64 if BACKBONE == 'MobileNetV2' else 32\nEPOCHS_HEAD = 3               # обучение только «головы»\nEPOCHS_FT   = 4               # fine-tuning верхних блоков backbone\nVAL_SIZE    = 0.1\nUSE_CACHE   = True            # кэш распакованных картинок на диск — 2-я и следующие эпохи идут заметно быстрее\n\nAUTOTUNE = tf.data.AUTOTUNE\n\nPREPROCESS = (tf.keras.applications.mobilenet_v2.preprocess_input\n              if BACKBONE == 'MobileNetV2'\n              else tf.keras.applications.vgg16.preprocess_input)\n\nprint(f'backbone={BACKBONE}, img={IMG_SIZE}, batch={BATCH_SIZE}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:12.449665Z","iopub.execute_input":"2026-09-19T08:51:12.449967Z","iopub.status.idle":"2026-09-19T08:51:12.458560Z","shell.execute_reply.started":"2026-09-19T08:51:12.449939Z","shell.execute_reply":"2026-09-19T08:51:12.457640Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Списки файлов и разбиение на train/val\n\nРазметка тут бесплатная: класс зашит в имя файла, `cat.123.jpg` → 0, `dog.123.jpg` → 1.\nДоговоримся сразу: **1 = собака**, потому что именно вероятность собаки просит Kaggle в сабмите.\nПерепутать классы местами — обидная ошибка, которая даёт log loss около 4 вместо 0.05.\n\nДальше откладываем 10% картинок в **валидацию**. Это данные, которые модель не видит при обучении;\nтолько на них можно честно оценить качество и подобрать клиппинг (раздел 8). Метки теста\nнам недоступны, так что валидация — единственный способ что-то проверить до отправки на Kaggle.\n\n`stratify=labels` сохраняет пропорцию кошек и собак в обеих частях (здесь классы и так\nсбалансированы 50/50, но привычка полезная — на несбалансированных данных без этого можно\nполучить валидацию, где какого-то класса почти нет).","metadata":{}},{"cell_type":"code","source":"train_files = sorted(glob.glob(os.path.join(TRAIN_DIR, '*.jpg')))\nassert len(train_files) > 0, f'В {TRAIN_DIR} нет картинок — проверьте распаковку'\n\nlabels = np.array([1.0 if os.path.basename(p).startswith('dog') else 0.0\n                   for p in train_files], dtype='float32')\n\ntr_files, va_files, tr_y, va_y = train_test_split(\n    train_files, labels, test_size=VAL_SIZE, stratify=labels, random_state=SEED)\n\nprint(f'train: {len(tr_files)} (собак {tr_y.mean():.3f}) | val: {len(va_files)} (собак {va_y.mean():.3f})')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:12.460546Z","iopub.execute_input":"2026-09-19T08:51:12.460845Z","iopub.status.idle":"2026-09-19T08:51:12.713210Z","shell.execute_reply.started":"2026-09-19T08:51:12.460817Z","shell.execute_reply":"2026-09-19T08:51:12.712090Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Пайплайн `tf.data`\n\n25 000 картинок — это около 1 ГБ на диске и в несколько раз больше в распакованном виде,\nцеликом в память они не поместятся. `tf.data` решает это конвейером: читает файлы по мере\nнадобности, обрабатывает их параллельно на CPU и подкладывает готовые батчи, пока GPU считает\nпредыдущий. Без такого конвейера видеокарта половину времени простаивает в ожидании данных.\n\nПорядок операций подобран не случайно:\n\n1. **чтение + декодирование JPEG + resize 224×224** — самая дорогая часть, тут работает CPU;\n2. **кэш на диск** (`.cache(файл)`) — после первой эпохи готовые картинки лежат распакованными,\n   и со второй эпохи шаг 1 не повторяется. В кэш кладём `uint8`: 150 КБ на картинку против\n   600 КБ у `float32`, то есть примерно 3.4 ГБ вместо 13 ГБ;\n3. **перемешивание** (`shuffle`) — только для обучающей выборки, чтобы батчи не состояли\n   из подряд идущих файлов (а они отсортированы по классам!);\n4. **нормализация** — уже после кэша, потому что она превращает `uint8` во `float32`;\n5. `batch` + `prefetch(AUTOTUNE)` — подготовка следующего батча параллельно с обучением.\n\nАугментации (случайные отражения, повороты) специально вынесены **в модель**, а не сюда:\nиначе в кэш попали бы уже искажённые картинки и на каждой эпохе повторялись бы одни и те же искажения.\n\n⚠️ Если прервать обучение на первой эпохе, файл кэша останется недописанным и следующий запуск\nупадёт с ошибкой. Поэтому ячейка начинается с удаления старого кэша.\n\nВ конце — быстрая проверка одного батча. Форма должна быть `(64, 224, 224, 3)`, тип `float32`,\nа диапазон значений — примерно от −1 до 1 (для MobileNetV2). Если диапазон 0…255, значит\nнормализация не применилась.","metadata":{}},{"cell_type":"code","source":"def _decode(path, flip=False):\n    img = tf.io.read_file(path)\n    img = tf.io.decode_jpeg(img, channels=3)\n    img = tf.image.resize(img, (IMG_SIZE, IMG_SIZE), method='bilinear')\n    if flip:\n        img = tf.image.flip_left_right(img)\n    return tf.cast(tf.clip_by_value(img, 0.0, 255.0), tf.uint8)\n\n\ndef make_ds(files, y=None, training=False, flip=False, cache_name=None, batch_size=None):\n    \"\"\"files -> tf.data.Dataset. y=None для теста (без меток).\"\"\"\n    bs = batch_size or BATCH_SIZE\n    ds = tf.data.Dataset.from_tensor_slices([str(f) for f in files])\n    ds = ds.map(lambda p: _decode(p, flip=flip), num_parallel_calls=AUTOTUNE)\n\n    if y is not None:\n        ds = tf.data.Dataset.zip((ds, tf.data.Dataset.from_tensor_slices(np.asarray(y, 'float32'))))\n\n    if cache_name is not None and USE_CACHE:\n        os.makedirs(CACHE_DIR, exist_ok=True)\n        ds = ds.cache(os.path.join(CACHE_DIR, cache_name))\n\n    if training:\n        ds = ds.shuffle(2048, seed=SEED, reshuffle_each_iteration=True)\n\n    if y is not None:\n        ds = ds.map(lambda x, lab: (PREPROCESS(tf.cast(x, tf.float32)), lab), num_parallel_calls=AUTOTUNE)\n    else:\n        ds = ds.map(lambda x: PREPROCESS(tf.cast(x, tf.float32)), num_parallel_calls=AUTOTUNE)\n\n    return ds.batch(bs).prefetch(AUTOTUNE)\n\n\n# на всякий случай чистим старый кэш (недописанный кэш от прерванного запуска ломает обучение)\nshutil.rmtree(CACHE_DIR, ignore_errors=True)\nos.makedirs(CACHE_DIR, exist_ok=True)\n\ntrain_ds = make_ds(tr_files, tr_y, training=True, cache_name='train')\nval_ds   = make_ds(va_files, va_y, cache_name='val')\n\nxb, yb = next(iter(train_ds))\nprint('батч:', xb.shape, xb.dtype, '| диапазон значений:', float(tf.reduce_min(xb)), '...', float(tf.reduce_max(xb)))\nprint('метки:', yb.shape, yb.dtype)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:12.715822Z","iopub.execute_input":"2026-09-19T08:51:12.716271Z","iopub.status.idle":"2026-09-19T08:51:14.891331Z","shell.execute_reply.started":"2026-09-19T08:51:12.716211Z","shell.execute_reply":"2026-09-19T08:51:14.890191Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Модель\n\nСобираем сеть из четырёх частей:\n\n```\nвход 224×224×3\n  └─ аугментации: случайное отражение / поворот / зум\n       └─ MobileNetV2 без головы (заморожен)   → карта признаков 7×7×1280\n            └─ GlobalAveragePooling2D          → вектор 1280\n                 └─ Dropout(0.3) → Dense(1, sigmoid) → вероятность собаки\n```\n\nРазберём по шагам, зачем каждая часть:\n\n* **Аугментации внутри модели.** Слои `RandomFlip / RandomRotation / RandomZoom` активны только\n  во время `fit` и полностью выключаются на `predict` — Keras сам различает эти режимы.\n  Модель каждую эпоху видит слегка разные варианты одной картинки и хуже запоминает\n  конкретные снимки, то есть меньше переобучается. Считается это на GPU и почти бесплатно.\n* **`include_top=False`.** Отрезаем «голову» ImageNet на 1000 классов — нам нужны только признаки.\n* **`base.trainable = False`.** На первом этапе веса предобученной части заморожены: учится\n  только новый слой. Так мы не портим готовые признаки случайными градиентами необученной головы.\n* **`base(x, training=False)`.** Тонкий, но важный момент. Внутри MobileNetV2 есть слои\n  BatchNormalization, которые в режиме обучения пересчитывают скользящие средние по батчу.\n  При fine-tuning на маленьком датасете эти статистики «уплывают» и качество разваливается,\n  поэтому мы явно фиксируем BatchNorm в режиме предсказания. Это стандартная рекомендация\n  из официального гайда Keras по transfer learning.\n* **`GlobalAveragePooling2D`.** Схлопывает карту признаков 7×7×1280 в вектор 1280, усредняя\n  по пространству. Альтернатива — `Flatten`, но он дал бы 62 720 входов и гору параметров.\n* **`Dropout(0.3)`.** Во время обучения случайно обнуляет 30% признаков — ещё одна страховка\n  от переобучения.\n* **`Dense(1, sigmoid)`.** Один нейрон, сигмоида сжимает результат в (0, 1) — это и есть\n  вероятность собаки, ровно то, что просит метрика.\n\nВ выводе `model.summary()` обратите внимание на строку Trainable params: на этом этапе их\nдолжно быть всего 1281 (1280 весов + смещение) из ~2.3 млн.","metadata":{}},{"cell_type":"code","source":"def build_model(backbone=BACKBONE, img_size=IMG_SIZE):\n    inputs = tf.keras.Input(shape=(img_size, img_size, 3))\n\n    x = tf.keras.layers.RandomFlip('horizontal', seed=SEED)(inputs)\n    x = tf.keras.layers.RandomRotation(0.05, seed=SEED)(x)\n    x = tf.keras.layers.RandomZoom(0.10, seed=SEED)(x)\n\n    if backbone == 'MobileNetV2':\n        base = tf.keras.applications.MobileNetV2(\n            input_shape=(img_size, img_size, 3), include_top=False, weights='imagenet')\n    elif backbone == 'VGG16':\n        base = tf.keras.applications.VGG16(\n            input_shape=(img_size, img_size, 3), include_top=False, weights='imagenet')\n    else:\n        raise ValueError(backbone)\n\n    base.trainable = False\n    x = base(x, training=False)\n\n    x = tf.keras.layers.GlobalAveragePooling2D()(x)\n    x = tf.keras.layers.Dropout(0.3)(x)\n    outputs = tf.keras.layers.Dense(1, activation='sigmoid')(x)\n\n    return tf.keras.Model(inputs, outputs, name=f'{backbone}_catsdogs'), base\n\n\nmodel, base = build_model()\nmodel.compile(optimizer=tf.keras.optimizers.Adam(1e-3),\n              loss='binary_crossentropy',\n              metrics=['accuracy'])\nmodel.summary()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:14.892545Z","iopub.execute_input":"2026-09-19T08:51:14.892824Z","iopub.status.idle":"2026-09-19T08:51:16.350563Z","shell.execute_reply.started":"2026-09-19T08:51:14.892799Z","shell.execute_reply":"2026-09-19T08:51:16.349513Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Этап 1 — обучаем только голову\n\nУчится один слой поверх замороженных признаков, поэтому можно смело брать большой шаг\nобучения `1e-3`. Loss — `binary_crossentropy`: это ровно та же формула, что и метрика контеста,\nтак что мы оптимизируем именно то, по чему нас оценивают.\n\nДва коллбэка страхуют от лишней работы и переобучения:\n\n* **`EarlyStopping(patience=2, restore_best_weights=True)`** — если `val_loss` не улучшается\n  две эпохи подряд, обучение останавливается, а веса откатываются к лучшей эпохе.\n  Без `restore_best_weights` мы бы сохранили последнее (то есть худшее) состояние.\n* **`ReduceLROnPlateau`** — уменьшает шаг обучения в 3 раза, когда прогресс замирает:\n  часто модель после этого делает ещё один рывок.\n\nЧего ожидать: уже после первой эпохи `val_accuracy` около 0.97–0.98. Это не магия —\nпросто кошки и собаки хорошо представлены в ImageNet, и признаки почти готовые.","metadata":{}},{"cell_type":"code","source":"callbacks = [\n    tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=2, restore_best_weights=True, verbose=1),\n    tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.3, patience=1, min_lr=1e-6, verbose=1),\n]\n\nhist_head = model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS_HEAD, callbacks=callbacks)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T08:51:16.351710Z","iopub.execute_input":"2026-09-19T08:51:16.352086Z","iopub.status.idle":"2026-09-19T09:24:38.104466Z","shell.execute_reply.started":"2026-09-19T08:51:16.352030Z","shell.execute_reply":"2026-09-19T09:24:38.102776Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Этап 2 — fine-tuning верхних блоков\n\nТеперь размораживаем верхнюю часть предобученной сети, чтобы признаки подстроились под нашу\nзадачу. Три правила fine-tuning, все три здесь выполнены:\n\n1. **Только после того, как голова обучена.** Иначе большие градиенты от случайной головы\n   испортят предобученные веса.\n2. **Очень маленький шаг обучения** (`1e-5` вместо `1e-3`). Мы не учим сеть заново, а\n   аккуратно подправляем — большой шаг просто сотрёт знания ImageNet.\n3. **Размораживаем только верх.** Нижние слои ловят универсальные вещи (края, градиенты,\n   текстуры) — их трогать незачем. Верхние отвечают за сложные составные признаки, вот они\n   и должны стать «про кошек и собак». `FREEZE_UNTIL` задаёт границу: у MobileNetV2 154 слоя,\n   учим примерно последнюю треть.\n\nОтдельно оставляем замороженными все BatchNorm-слои — по той же причине, что и в разделе 5.\n\nПосле `model.compile` число обучаемых параметров вырастет с 1281 до примерно 1.8 млн —\nэто печатается в ячейке. `val_loss` на этом этапе обычно падает с ~0.07 до 0.03–0.05.","metadata":{}},{"cell_type":"code","source":"FREEZE_UNTIL = 100 if BACKBONE == 'MobileNetV2' else 15   # MobileNetV2: 154 слоя, VGG16: 19\n\nbase.trainable = True\nfor layer in base.layers[:FREEZE_UNTIL]:\n    layer.trainable = False\nfor layer in base.layers:\n    if isinstance(layer, tf.keras.layers.BatchNormalization):\n        layer.trainable = False\n\nmodel.compile(optimizer=tf.keras.optimizers.Adam(1e-5),\n              loss='binary_crossentropy',\n              metrics=['accuracy'])\n\nprint('обучаемых параметров:', f'{int(sum(np.prod(w.shape) for w in model.trainable_weights)):,}')\n\nhist_ft = model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS_FT, callbacks=callbacks)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T09:24:38.108775Z","iopub.execute_input":"2026-09-19T09:24:38.109272Z","iopub.status.idle":"2026-09-19T10:20:17.980519Z","shell.execute_reply.started":"2026-09-19T09:24:38.109236Z","shell.execute_reply":"2026-09-19T10:20:17.976954Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Оценка на валидации + два приёма против log loss\n\nЗдесь мы уже не учим модель, а выжимаем метрику на тех же предсказаниях. Два приёма:\n\n**TTA (Test Time Augmentation).** Предсказываем каждую картинку дважды — как есть и\nотражённой по горизонтали — и усредняем. Кошка в зеркале остаётся кошкой, а вот ошибки\nмодели на двух вариантах часто разные, и усреднение их частично гасит. Стоит это ровно\nвдвое дороже по времени предсказания, обучения не требует. Проверяем по валидации,\nпомогло ли: если нет — не используем.\n\n**Клиппинг.** Вернёмся к таблице из начала ноутбука: ошибка с вероятностью 0.999 стоит 6.9,\nа с 0.99 — «всего» 4.6. Если обрезать все предсказания в диапазон $[c,\\,1-c]$, мы чуть-чуть\nпроигрываем на правильных ответах, но сильно экономим на редких уверенных ошибках. При\nточности около 99% это почти всегда выгодно. Величину $c$ не угадываем, а перебираем по сетке\nи берём ту, что минимизирует log loss **на валидации** — на тесте её подбирать не по чему.\n\nВажно: подбирать что-либо по тестовым предсказаниям нельзя, там нет меток. Валидация для того\nи откладывалась. Ожидаемый результат в этой ячейке: accuracy 0.98–0.99, log loss 0.03–0.07\n(порог задания — 0.3, то есть запас большой).","metadata":{}},{"cell_type":"code","source":"val_eval      = make_ds(va_files)                 # без меток и без перемешивания — порядок совпадает с va_files\nval_eval_flip = make_ds(va_files, flip=True)\n\np_val      = model.predict(val_eval, verbose=1).ravel()\np_val_flip = model.predict(val_eval_flip, verbose=1).ravel()\np_val_tta  = (p_val + p_val_flip) / 2\n\nprint(f'accuracy (val)      : {accuracy_score(va_y, (p_val > 0.5).astype(int)):.4f}')\nprint(f'log loss (val)      : {log_loss(va_y, np.clip(p_val, 1e-15, 1 - 1e-15)):.5f}')\nprint(f'log loss (val + TTA): {log_loss(va_y, np.clip(p_val_tta, 1e-15, 1 - 1e-15)):.5f}')\n\nUSE_TTA = log_loss(va_y, np.clip(p_val_tta, 1e-15, 1 - 1e-15)) <= log_loss(va_y, np.clip(p_val, 1e-15, 1 - 1e-15))\np_best = p_val_tta if USE_TTA else p_val\nprint('TTA используем:', USE_TTA)\n\ngrid = [0.0, 1e-4, 3e-4, 1e-3, 3e-3, 5e-3, 1e-2, 2e-2, 3e-2, 5e-2]\nscores = {c: log_loss(va_y, np.clip(p_best, max(c, 1e-15), 1 - max(c, 1e-15))) for c in grid}\nCLIP = min(scores, key=scores.get)\n\nfor c, s in scores.items():\n    print(f'  clip={c:<7} log loss={s:.5f}' + ('   <-- лучший' if c == CLIP else ''))\nprint(f'\\nИтоговый клиппинг: {CLIP}, log loss на валидации: {scores[CLIP]:.5f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T10:20:17.991107Z","iopub.execute_input":"2026-09-19T10:20:17.991852Z","iopub.status.idle":"2026-09-19T10:22:13.744251Z","shell.execute_reply.started":"2026-09-19T10:20:17.991784Z","shell.execute_reply":"2026-09-19T10:22:13.743158Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Предсказание на test и submission.csv\n\nФормат сабмита простой: две колонки `id,label`, где `id` — номер файла из папки `test/`\n(`4242.jpg` → `4242`), а `label` — вероятность собаки.\n\nЕдинственная тонкость — **порядок**. `glob` возвращает файлы в порядке файловой системы,\nа строковая сортировка поставит `10.jpg` раньше `2.jpg`. Поэтому сортируем по числу в имени\nи из тех же имён берём `id` — так вероятность гарантированно соответствует своей картинке.\n\nК предсказаниям применяем TTA и клиппинг ровно с теми настройками, которые выиграли на валидации.\nСреднее предсказание по тесту должно быть около 0.5: в тестовой выборке кошек и собак поровну.\nСильное отклонение — повод проверить, не перепутаны ли классы.","metadata":{}},{"cell_type":"code","source":"test_files = sorted(glob.glob(os.path.join(TEST_DIR, '*.jpg')),\n                    key=lambda p: int(os.path.splitext(os.path.basename(p))[0]))\ntest_ids = [int(os.path.splitext(os.path.basename(p))[0]) for p in test_files]\nprint('тестовых картинок:', len(test_files))\n\np_test = model.predict(make_ds(test_files), verbose=1).ravel()\nif USE_TTA:\n    p_test = (p_test + model.predict(make_ds(test_files, flip=True), verbose=1).ravel()) / 2\n\np_test = np.clip(p_test, max(CLIP, 1e-15), 1 - max(CLIP, 1e-15))\n\nsubmission = pd.DataFrame({'id': test_ids, 'label': p_test})\nsubmission.to_csv(SUB_PATH, index=False)\n\nprint(submission.head())\nprint('\\nразмер:', submission.shape, '| среднее предсказание:', round(float(submission.label.mean()), 4))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T10:22:13.745991Z","iopub.execute_input":"2026-09-19T10:22:13.746409Z","iopub.status.idle":"2026-09-19T10:31:43.450626Z","shell.execute_reply.started":"2026-09-19T10:22:13.746379Z","shell.execute_reply":"2026-09-19T10:31:43.449628Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\n\n# Значения взяты из логов обучения выше (сессия перезапускалась, объекты history не сохранились)\nhead = {'loss': [0.1222, 0.0545, 0.0495], 'val_loss': [0.0449, 0.0387, 0.0385],\n        'accuracy': [0.9530, 0.9804, 0.9819], 'val_accuracy': [0.9840, 0.9880, 0.9880]}\nft = {'loss': [0.0426, 0.0323, 0.0267, 0.0203], 'val_loss': [0.0309, 0.0299, 0.0341, 0.0302],\n      'accuracy': [0.9847, 0.9880, 0.9906, 0.9928], 'val_accuracy': [0.9892, 0.9868, 0.9872, 0.9888]}\n\nh = {k: head[k] + ft[k] for k in head}\nn_head, epochs = len(head['loss']), range(1, len(head['loss']) + len(ft['loss']) + 1)\n\nfig, axes = plt.subplots(1, 2, figsize=(13, 4))\nfor ax, (m, title) in zip(axes, [('loss', 'Binary crossentropy'), ('accuracy', 'Accuracy')]):\n    ax.plot(epochs, h[m], marker='o', label='train')\n    ax.plot(epochs, h['val_' + m], marker='o', label='val')\n    ax.axvline(n_head + 0.5, ls='--', c='gray')\n    ax.annotate('fine-tuning →', xy=(n_head + 0.6, ax.get_ylim()[1]), va='top', fontsize=9, color='gray')\n    ax.set_title(title); ax.set_xlabel('эпоха'); ax.grid(alpha=.3); ax.legend()\nplt.suptitle('Обучение MobileNetV2: 3 эпохи головы, затем fine-tuning')\nplt.tight_layout(); plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-19T11:41:51.518995Z","iopub.execute_input":"2026-09-19T11:41:51.519452Z","iopub.status.idle":"2026-09-19T11:41:51.981396Z","shell.execute_reply.started":"2026-09-19T11:41:51.519415Z","shell.execute_reply":"2026-09-19T11:41:51.980297Z"}},"outputs":[],"execution_count":null}]}