{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.12"},"kaggle":{"accelerator":"none","dataSources":[{"sourceType":"competition","sourceId":19989,"databundleVersionId":1160143},{"sourceType":"datasetVersion","sourceId":15097221,"datasetId":9665751,"databundleVersionId":15982204}],"dockerImageVersionId":31287,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"7ae83970-37cb-4a9b-bcb5-061b3bf4bfb0","cell_type":"markdown","source":"# Global Wheat Detection\n\nСсылка на соревнование: https://www.kaggle.com/competitions/global-wheat-detection\n\nПостановка задачи:\n- по изображению предсказать bounding boxes для объектов класса `wheat`\n\nИдея решения:\n- сначала собрать рабочий `Skeleton`: `YOLO11n` + простой hold-out split + первый submit\n- затем проверить `Hypothesis 0`: помогает ли более аккуратный validation split\n- затем проверить `Hypothesis 1`: растет ли качество, если заменить `YOLO11n` на `YOLO11s`\n\nДополнительные комментарии:\n- весь train/val/predict код находится прямо в этом ноутбуке\n- ноутбук можно запускать локально и на Kaggle\n- `local kaggle_score` повторяет competition metric на `IoU 0.50:0.05:0.75`\n- historical `public/private LB` ниже зафиксированы по последним clean submissions\n","metadata":{}},{"id":"73a46cca-0503-42c6-8d51-fdaf7e9d8abc","cell_type":"code","source":"from __future__ import annotations\n\nfrom collections import defaultdict\nfrom pathlib import Path\nfrom typing import Any\nimport ast\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom sklearn.model_selection import train_test_split\n\ntry:\n    from IPython.display import display\nexcept Exception:\n    def display(obj):\n        print(obj)\n\n\ndef find_runtime_files(pattern: str) -> list[Path]:\n    roots = [Path('/kaggle/input'), Path('/kaggle/working'), Path.cwd()]\n    matches = []\n    for root in roots:\n        if not root.exists():\n            continue\n        matches.extend(root.rglob(pattern))\n    return sorted(set(matches), key=lambda path: (len(path.parts), str(path)))\n\n\ndef ensure_ultralytics() -> None:\n    try:\n        import ultralytics  # noqa: F401\n        return\n    except ImportError:\n        pass\n\n    wheels = find_runtime_files('ultralytics-*.whl')\n    if not wheels:\n        raise FileNotFoundError('ultralytics wheel not found in Kaggle inputs or working directory')\n\n    cmd = [sys.executable, '-m', 'pip', 'install', '-q', '--no-deps']\n    thop_wheels = find_runtime_files('thop-*.whl')\n    if thop_wheels:\n        cmd.append(str(thop_wheels[0]))\n    cmd.append(str(wheels[0]))\n    subprocess.run(cmd, check=True)\n\n\nensure_ultralytics()\n\nfrom ultralytics import YOLO\nfrom ultralytics.utils import SETTINGS\n\n\nCOMPETITION_NAME = 'global-wheat-detection'\nCLASS_NAMES = ['wheat']\nKAGGLE_IOU_THRESHOLDS = tuple(np.arange(0.5, 0.76, 0.05))\nIS_KAGGLE = Path('/kaggle').exists()\n\nif IS_KAGGLE:\n    COURSE_DIR = Path('/kaggle/working')\n    PROJECT_ROOT = COURSE_DIR\n    data_candidates = [Path('/kaggle/input') / COMPETITION_NAME]\nelse:\n    cwd = Path.cwd().resolve()\n    course_candidates = [cwd, cwd / 'kaggle_course', cwd.parent / 'kaggle_course']\n    COURSE_DIR = next(\n        (path for path in course_candidates if (path / '00-ml-trainings.ipynb').exists()),\n        None,\n    )\n    if COURSE_DIR is None:\n        raise FileNotFoundError('Не удалось определить папку kaggle_course')\n    PROJECT_ROOT = COURSE_DIR.parent\n    data_candidates = [\n        PROJECT_ROOT / 'data' / COMPETITION_NAME,\n        COURSE_DIR / 'data' / COMPETITION_NAME,\n    ]\n\nCOMPETITION_DIR = next((path for path in data_candidates if (path / 'train.csv').exists()), None)\nif COMPETITION_DIR is None:\n    raise FileNotFoundError('Не нашел train.csv для соревнования')\n\nWORKING_DIR = Path('/kaggle/working') if IS_KAGGLE else COURSE_DIR\nRUNS_DIR = WORKING_DIR / 'runs'\nTRAIN_CSV = COMPETITION_DIR / 'train.csv'\nTRAIN_IMAGES_DIR = COMPETITION_DIR / 'train'\nTEST_IMAGES_DIR = COMPETITION_DIR / 'test'\nSAMPLE_SUBMISSION_PATH = COMPETITION_DIR / 'sample_submission.csv'\n\nSTAGES = {\n    'Skeleton': {\n        'run_name': 'wheat_yolo11n',\n        'model': 'yolo11n.pt',\n        'split_strategy': 'random',\n        'dataset_dir_name': 'wheat_dataset_skeleton',\n        'summary': 'YOLO11n + random hold-out split',\n        'train_batch': 32,\n    },\n    'Hypothesis 0': {\n        'run_name': 'wheat_yolo11n_sourcebox',\n        'model': 'yolo11n.pt',\n        'split_strategy': 'source_box_stratified',\n        'dataset_dir_name': 'wheat_dataset_h0',\n        'summary': 'YOLO11n + source/box stratified split',\n        'train_batch': 32,\n    },\n    'Hypothesis 1': {\n        'run_name': 'wheat_yolo11s',\n        'model': 'yolo11s.pt',\n        'split_strategy': 'random',\n        'dataset_dir_name': 'wheat_dataset_h1',\n        'summary': 'YOLO11s + baseline split',\n        'train_batch': 16,\n    },\n}\n\nLB_SCORES = {\n    'Skeleton': {'public_lb': 0.5643, 'private_lb': 0.4602},\n    'Hypothesis 0': {'public_lb': 0.5684, 'private_lb': 0.4790},\n    'Hypothesis 1': {'public_lb': 0.6136, 'private_lb': 0.4968},\n}\n\nCOMMON_ARGS = {\n    'epochs': 10,\n    'imgsz': 640,\n    'pred_conf': 0.15,\n    'seed': 42,\n    'val_split': 0.15,\n}\n\nACTIVE_STAGE = 'Hypothesis 1'\nRUN_SELECTED_STAGE = True\nFORCE_RETRAIN = False\nVALID_ACTIVE_STAGE = {'analysis', 'Skeleton', 'Hypothesis 0', 'Hypothesis 1', 'all'}\nif ACTIVE_STAGE not in VALID_ACTIVE_STAGE:\n    raise ValueError(f'ACTIVE_STAGE must be one of {sorted(VALID_ACTIVE_STAGE)}')\n\nconfig_df = pd.DataFrame([\n    {\n        'active_stage': ACTIVE_STAGE,\n        'run_selected_stage': RUN_SELECTED_STAGE,\n        'force_retrain': FORCE_RETRAIN,\n        'working_dir': str(WORKING_DIR),\n        'competition_dir': str(COMPETITION_DIR),\n    }\n])\ndisplay(config_df)\n\nSTAGE_RESULTS: dict[str, dict[str, Any]] = {}\n\n\ndef default_device() -> str:\n    if torch.cuda.is_available():\n        return '0'\n    if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():\n        return 'mps'\n    return 'cpu'\n\n\ndef find_all(pattern: str) -> list[Path]:\n    roots = [Path('/kaggle/input'), WORKING_DIR] if IS_KAGGLE else [PROJECT_ROOT, COURSE_DIR, WORKING_DIR]\n    matches = []\n    for root in roots:\n        if root.exists():\n            matches.extend(root.rglob(pattern))\n    return sorted(set(matches), key=lambda path: (len(path.parts), str(path)))\n\n\ndef find_single(pattern: str) -> Path | None:\n    matches = find_all(pattern)\n    return matches[0] if matches else None\n\n\ndef resolve_model_path(model_name: str) -> Path:\n    found = find_single(model_name)\n    return found if found is not None else Path(model_name)\n\n\ndef disable_problematic_ultralytics_integrations() -> None:\n    try:\n        SETTINGS['raytune'] = False\n    except Exception:\n        pass\n\n    try:\n        from ultralytics.utils.callbacks import raytune as raytune_callbacks\n    except Exception:\n        return\n\n    callbacks = getattr(raytune_callbacks, 'callbacks', None)\n    if isinstance(callbacks, dict):\n        callbacks.clear()\n\n\ndef ensure_dir(path: Path) -> Path:\n    path.mkdir(parents=True, exist_ok=True)\n    return path\n\n\ndef write_text(path: Path, content: str) -> None:\n    ensure_dir(path.parent)\n    path.write_text(content, encoding='utf-8')\n\n\ndef safe_link_or_copy(src: Path, dst: Path) -> None:\n    if dst.exists():\n        return\n    try:\n        os.symlink(src.resolve(), dst)\n    except FileExistsError:\n        return\n    except OSError:\n        shutil.copy2(src, dst)\n\n\ndef stage_dataset_dir(stage_name: str) -> Path:\n    return WORKING_DIR / STAGES[stage_name]['dataset_dir_name']\n\n\ndef stage_split_path(stage_name: str) -> Path:\n    return stage_dataset_dir(stage_name) / 'split.json'\n\n\ndef stage_results_path(stage_name: str) -> Path:\n    return RUNS_DIR / STAGES[stage_name]['run_name'] / 'results.json'\n\n\ndef stage_best_ckpt(stage_name: str) -> Path:\n    run_name = STAGES[stage_name]['run_name']\n    local_ckpt = RUNS_DIR / run_name / 'weights' / 'best.pt'\n    if local_ckpt.exists():\n        return local_ckpt\n\n    artifact_ckpt = find_single(f'{run_name}_best.pt')\n    if artifact_ckpt is not None:\n        return artifact_ckpt\n\n    return local_ckpt\n\n\ndef stage_submission_path(stage_name: str) -> Path:\n    return WORKING_DIR / f\"submission_{STAGES[stage_name]['run_name']}.csv\"\n\n\ndef canonical_submission_path() -> Path:\n    return WORKING_DIR / 'submission.csv'\n\n\ndef to_jsonable(value: Any) -> Any:\n    if isinstance(value, dict):\n        return {key: to_jsonable(val) for key, val in value.items()}\n    if isinstance(value, (list, tuple)):\n        return [to_jsonable(item) for item in value]\n    if isinstance(value, np.ndarray):\n        return value.tolist()\n    if isinstance(value, np.generic):\n        return value.item()\n    return value\n\n\ndef load_json(path: Path) -> dict:\n    return json.loads(path.read_text(encoding='utf-8'))\n\n\ndef load_boxes(train_csv: Path, train_images_dir: Path) -> tuple[dict[str, list[dict[str, float]]], dict[str, np.ndarray], list[str], pd.DataFrame]:\n    df = pd.read_csv(train_csv)\n    image_boxes: dict[str, list[dict[str, float]]] = defaultdict(list)\n    image_gt_boxes: dict[str, list[list[float]]] = defaultdict(list)\n    for _, row in df.iterrows():\n        xmin, ymin, w, h = ast.literal_eval(row['bbox'])\n        img_w, img_h = row['width'], row['height']\n        xc = (xmin + w / 2) / img_w\n        yc = (ymin + h / 2) / img_h\n        nw = w / img_w\n        nh = h / img_h\n        image_boxes[row['image_id']].append({'xc': xc, 'yc': yc, 'w': nw, 'h': nh})\n        image_gt_boxes[row['image_id']].append([xmin, ymin, xmin + w, ymin + h])\n\n    existing = {path.stem for path in train_images_dir.glob('*.jpg')}\n    image_ids = sorted(image_id for image_id in image_boxes if image_id in existing)\n    gt_boxes = {\n        image_id: np.asarray(boxes, dtype=np.float32)\n        for image_id, boxes in image_gt_boxes.items()\n        if image_id in existing\n    }\n    image_meta = (\n        df.groupby('image_id')\n        .agg(source=('source', 'first'), box_count=('bbox', 'size'))\n        .reset_index()\n    )\n    image_meta = image_meta[image_meta['image_id'].isin(existing)].sort_values('image_id').reset_index(drop=True)\n    return image_boxes, gt_boxes, image_ids, image_meta\n\n\ndef build_random_split(image_ids: list[str], seed: int, val_split: float) -> tuple[list[str], list[str]]:\n    rng = np.random.default_rng(seed)\n    shuffled = np.array(image_ids, dtype=object)\n    rng.shuffle(shuffled)\n    n_val = max(1, int(len(shuffled) * val_split))\n    val_ids = sorted(shuffled[:n_val].tolist())\n    train_ids = sorted(shuffled[n_val:].tolist())\n    return train_ids, val_ids\n\n\ndef build_source_box_stratified_split(image_meta: pd.DataFrame, seed: int, val_split: float) -> tuple[list[str], list[str]]:\n    work = image_meta.copy()\n    work['box_bin'] = pd.qcut(\n        work['box_count'].rank(method='first'),\n        q=min(5, work['box_count'].nunique()),\n        labels=False,\n        duplicates='drop',\n    ).astype(str)\n    work['stratify_label'] = work['source'].astype(str) + '__' + work['box_bin']\n    label_counts = work['stratify_label'].value_counts()\n    rare_labels = set(label_counts[label_counts < 2].index)\n    if rare_labels:\n        work.loc[work['stratify_label'].isin(rare_labels), 'stratify_label'] = work['source'].astype(str)\n    label_counts = work['stratify_label'].value_counts()\n    rare_labels = set(label_counts[label_counts < 2].index)\n    if rare_labels:\n        work.loc[work['stratify_label'].isin(rare_labels), 'stratify_label'] = 'fallback_all'\n\n    train_df, val_df = train_test_split(\n        work,\n        test_size=val_split,\n        random_state=seed,\n        shuffle=True,\n        stratify=work['stratify_label'],\n    )\n    return sorted(train_df['image_id'].tolist()), sorted(val_df['image_id'].tolist())\n\n\ndef prepare_yolo_dataset(dataset_dir: Path, train_images_dir: Path, image_boxes: dict[str, list[dict[str, float]]], train_ids: list[str], val_ids: list[str]) -> Path:\n    for split in ('train', 'val'):\n        ensure_dir(dataset_dir / 'images' / split)\n        ensure_dir(dataset_dir / 'labels' / split)\n\n    for split, image_ids in (('train', train_ids), ('val', val_ids)):\n        img_dir = dataset_dir / 'images' / split\n        lbl_dir = dataset_dir / 'labels' / split\n        for image_id in image_ids:\n            src = train_images_dir / f'{image_id}.jpg'\n            dst = img_dir / f'{image_id}.jpg'\n            safe_link_or_copy(src, dst)\n            lines = [\n                f\"0 {box['xc']:.6f} {box['yc']:.6f} {box['w']:.6f} {box['h']:.6f}\"\n                for box in image_boxes[image_id]\n            ]\n            write_text(lbl_dir / f'{image_id}.txt', '\\n'.join(lines))\n\n    yaml_path = dataset_dir / 'data.yaml'\n    write_text(\n        yaml_path,\n        '\\n'.join([\n            f'path: {dataset_dir.resolve()}',\n            'train: images/train',\n            'val: images/val',\n            f'nc: {len(CLASS_NAMES)}',\n            f'names: {CLASS_NAMES}',\n            '',\n        ]),\n    )\n    return yaml_path\n\n\ndef pred_to_string(results: list[Any]) -> str:\n    if results[0].boxes is None or len(results[0].boxes) == 0:\n        return ''\n    parts = []\n    for conf, xyxy in zip(results[0].boxes.conf.cpu().numpy(), results[0].boxes.xyxy.cpu().numpy()):\n        x1, y1, x2, y2 = xyxy\n        bw, bh = x2 - x1, y2 - y1\n        parts.append(f'{conf:.4f} {x1:.0f} {y1:.0f} {bw:.0f} {bh:.0f}')\n    return ' '.join(parts)\n\n\ndef clip_boxes_xyxy(boxes: np.ndarray, width: int, height: int) -> np.ndarray:\n    if boxes.size == 0:\n        return boxes.reshape(0, 4).astype(np.float32)\n    clipped = boxes.astype(np.float32).copy()\n    clipped[:, 0] = np.clip(clipped[:, 0], 0.0, max(float(width - 1), 0.0))\n    clipped[:, 1] = np.clip(clipped[:, 1], 0.0, max(float(height - 1), 0.0))\n    clipped[:, 2] = np.clip(clipped[:, 2], clipped[:, 0] + 1e-6, float(width))\n    clipped[:, 3] = np.clip(clipped[:, 3], clipped[:, 1] + 1e-6, float(height))\n    return clipped\n\n\ndef box_iou_xyxy(first_box: np.ndarray, second_box: np.ndarray) -> float:\n    x_left = max(float(first_box[0]), float(second_box[0]))\n    y_top = max(float(first_box[1]), float(second_box[1]))\n    x_right = min(float(first_box[2]), float(second_box[2]))\n    y_bottom = min(float(first_box[3]), float(second_box[3]))\n\n    inter_w = max(0.0, x_right - x_left)\n    inter_h = max(0.0, y_bottom - y_top)\n    inter_area = inter_w * inter_h\n    if inter_area <= 0.0:\n        return 0.0\n\n    first_area = max(0.0, float(first_box[2] - first_box[0])) * max(0.0, float(first_box[3] - first_box[1]))\n    second_area = max(0.0, float(second_box[2] - second_box[0])) * max(0.0, float(second_box[3] - second_box[1]))\n    union_area = first_area + second_area - inter_area\n    if union_area <= 0.0:\n        return 0.0\n    return inter_area / union_area\n\n\ndef kaggle_precision_at_threshold(gt_boxes: np.ndarray, pred_boxes: np.ndarray, iou_threshold: float) -> float:\n    if len(gt_boxes) == 0 and len(pred_boxes) == 0:\n        return 1.0\n    if len(gt_boxes) == 0 or len(pred_boxes) == 0:\n        return 0.0\n\n    matched_gt = np.zeros(len(gt_boxes), dtype=bool)\n    true_positives = 0\n    false_positives = 0\n\n    for pred_box in pred_boxes:\n        best_match_idx = -1\n        best_match_iou = iou_threshold\n        for gt_index, gt_box in enumerate(gt_boxes):\n            if matched_gt[gt_index]:\n                continue\n            iou = box_iou_xyxy(gt_box, pred_box)\n            if iou >= best_match_iou:\n                best_match_iou = iou\n                best_match_idx = gt_index\n        if best_match_idx >= 0:\n            matched_gt[best_match_idx] = True\n            true_positives += 1\n        else:\n            false_positives += 1\n\n    false_negatives = int((~matched_gt).sum())\n    denominator = true_positives + false_positives + false_negatives\n    return 0.0 if denominator == 0 else true_positives / denominator\n\n\ndef iter_batches(items: list[str], batch_size: int):\n    batch_size = max(1, int(batch_size))\n    for start in range(0, len(items), batch_size):\n        yield items[start:start + batch_size]\n\n\ndef run_kaggle_validation(best_ckpt: Path, val_image_ids: list[str], train_images_dir: Path, gt_boxes: dict[str, np.ndarray], imgsz: int, pred_conf: float, device: str, batch: int) -> dict[str, Any]:\n    model = YOLO(str(best_ckpt))\n    image_scores = []\n    threshold_to_scores = {f'{threshold:.2f}': [] for threshold in KAGGLE_IOU_THRESHOLDS}\n\n    for batch_ids in iter_batches(val_image_ids, batch):\n        batch_paths = [str(train_images_dir / f'{image_id}.jpg') for image_id in batch_ids]\n        batch_results = list(\n            model.predict(\n                source=batch_paths,\n                stream=True,\n                imgsz=imgsz,\n                conf=pred_conf,\n                batch=len(batch_paths),\n                device=device,\n                verbose=False,\n            )\n        )\n        for image_id, result in zip(batch_ids, batch_results):\n            pred_boxes = np.empty((0, 4), dtype=np.float32)\n            boxes = getattr(result, 'boxes', None)\n            if boxes is not None and len(boxes) > 0:\n                pred_boxes = boxes.xyxy.detach().cpu().numpy().astype(np.float32)\n                pred_scores = boxes.conf.detach().cpu().numpy().astype(np.float32)\n                order = np.argsort(-pred_scores, kind='stable')\n                pred_boxes = pred_boxes[order]\n                height, width = result.orig_shape\n                pred_boxes = clip_boxes_xyxy(pred_boxes, width=width, height=height)\n\n            gt_image_boxes = gt_boxes.get(image_id)\n            if gt_image_boxes is None:\n                gt_image_boxes = np.empty((0, 4), dtype=np.float32)\n\n            per_threshold_scores = []\n            for threshold in KAGGLE_IOU_THRESHOLDS:\n                threshold_score = kaggle_precision_at_threshold(gt_image_boxes, pred_boxes, float(threshold))\n                threshold_to_scores[f'{threshold:.2f}'].append(threshold_score)\n                per_threshold_scores.append(threshold_score)\n            image_scores.append(float(np.mean(per_threshold_scores)))\n\n    threshold_means = {\n        threshold: float(np.mean(scores)) if scores else 0.0\n        for threshold, scores in threshold_to_scores.items()\n    }\n    return {\n        'kaggle_score': float(np.mean(image_scores)) if image_scores else 0.0,\n        'kaggle_iou_thresholds': [float(threshold) for threshold in KAGGLE_IOU_THRESHOLDS],\n        'kaggle_score_by_threshold': threshold_means,\n        'kaggle_num_images': len(image_scores),\n    }\n\n\ndef run_validation(best_ckpt: Path, yaml_path: Path, val_image_ids: list[str], train_images_dir: Path, gt_boxes: dict[str, np.ndarray], imgsz: int, pred_conf: float, device: str, batch: int) -> dict[str, Any]:\n    model = YOLO(str(best_ckpt))\n    metrics = model.val(data=str(yaml_path), split='val', device=device)\n    box = metrics.box\n    cv_metrics = {\n        'map50': getattr(box, 'map50', None),\n        'map50_95': getattr(box, 'map', None),\n        'precision': getattr(box, 'p', None),\n        'recall': getattr(box, 'r', None),\n    }\n    cv_metrics.update(\n        run_kaggle_validation(\n            best_ckpt=best_ckpt,\n            val_image_ids=val_image_ids,\n            train_images_dir=train_images_dir,\n            gt_boxes=gt_boxes,\n            imgsz=imgsz,\n            pred_conf=pred_conf,\n            device=device,\n            batch=batch,\n        )\n    )\n    return cv_metrics\n\n\ndef build_submission(ckpt: str | Path, test_images_dir: Path, out_path: Path, imgsz: int, pred_conf: float, device: str, sample_submission_path: Path | None = None, test_subset: int | None = None) -> Path:\n    model = YOLO(str(ckpt))\n    if sample_submission_path is not None and sample_submission_path.exists():\n        test_ids = pd.read_csv(sample_submission_path)['image_id'].astype(str).tolist()\n    else:\n        test_ids = sorted(path.stem for path in test_images_dir.glob('*.jpg'))\n    if test_subset:\n        test_ids = test_ids[:test_subset]\n\n    rows = []\n    for image_id in test_ids:\n        img_path = test_images_dir / f'{image_id}.jpg'\n        result = model.predict(str(img_path), imgsz=imgsz, conf=pred_conf, verbose=False, device=device)\n        rows.append({'image_id': image_id, 'PredictionString': pred_to_string(result)})\n\n    pd.DataFrame(rows).to_csv(out_path, index=False)\n    return out_path\n\n\ndef run_stage(stage_name: str, retrain: bool = False) -> dict[str, Any]:\n    disable_problematic_ultralytics_integrations()\n    stage = STAGES[stage_name]\n    device = default_device()\n    batch = stage['train_batch']\n    dataset_dir = stage_dataset_dir(stage_name)\n    results_path = stage_results_path(stage_name)\n    best_ckpt = stage_best_ckpt(stage_name)\n\n    image_boxes, gt_boxes, image_ids, image_meta_stage = load_boxes(TRAIN_CSV, TRAIN_IMAGES_DIR)\n    if stage['split_strategy'] == 'random':\n        train_ids, val_ids = build_random_split(image_ids, seed=COMMON_ARGS['seed'], val_split=COMMON_ARGS['val_split'])\n    else:\n        train_ids, val_ids = build_source_box_stratified_split(image_meta_stage, seed=COMMON_ARGS['seed'], val_split=COMMON_ARGS['val_split'])\n\n    split_payload = {\n        'split_strategy': stage['split_strategy'],\n        'seed': COMMON_ARGS['seed'],\n        'val_split': COMMON_ARGS['val_split'],\n        'train_count': len(train_ids),\n        'val_count': len(val_ids),\n        'train_ids': train_ids,\n        'val_ids': val_ids,\n    }\n    write_text(stage_split_path(stage_name), json.dumps(split_payload, indent=2))\n    yaml_path = prepare_yolo_dataset(dataset_dir, TRAIN_IMAGES_DIR, image_boxes, train_ids, val_ids)\n\n    if retrain or not best_ckpt.exists():\n        model = YOLO(str(resolve_model_path(stage['model'])))\n        model.train(\n            data=str(yaml_path),\n            epochs=COMMON_ARGS['epochs'],\n            imgsz=COMMON_ARGS['imgsz'],\n            batch=batch,\n            device=device,\n            project=str(RUNS_DIR),\n            name=stage['run_name'],\n            exist_ok=True,\n        )\n        if not best_ckpt.exists():\n            raise FileNotFoundError(f'Expected checkpoint not found: {best_ckpt}')\n\n    cv_metrics = run_validation(\n        best_ckpt=best_ckpt,\n        yaml_path=yaml_path,\n        val_image_ids=val_ids,\n        train_images_dir=TRAIN_IMAGES_DIR,\n        gt_boxes=gt_boxes,\n        imgsz=COMMON_ARGS['imgsz'],\n        pred_conf=COMMON_ARGS['pred_conf'],\n        device=device,\n        batch=batch,\n    )\n    submission_path = build_submission(\n        ckpt=best_ckpt,\n        test_images_dir=TEST_IMAGES_DIR,\n        out_path=stage_submission_path(stage_name),\n        imgsz=COMMON_ARGS['imgsz'],\n        pred_conf=COMMON_ARGS['pred_conf'],\n        device=device,\n        sample_submission_path=SAMPLE_SUBMISSION_PATH,\n    )\n    canonical_submission = canonical_submission_path()\n    if submission_path != canonical_submission:\n        shutil.copyfile(submission_path, canonical_submission)\n\n    payload = to_jsonable({\n        'competition_dir': str(COMPETITION_DIR),\n        'sample_submission_exists': SAMPLE_SUBMISSION_PATH.exists(),\n        'device': device,\n        'model': stage['model'],\n        'run_name': stage['run_name'],\n        'epochs': COMMON_ARGS['epochs'],\n        'imgsz': COMMON_ARGS['imgsz'],\n        'batch': batch,\n        'pred_conf': COMMON_ARGS['pred_conf'],\n        'split_strategy': stage['split_strategy'],\n        'seed': COMMON_ARGS['seed'],\n        'val_split': COMMON_ARGS['val_split'],\n        'train_count': len(train_ids),\n        'val_count': len(val_ids),\n        'best_ckpt': str(best_ckpt),\n        'yaml_path': str(yaml_path),\n        'split_path': str(stage_split_path(stage_name)),\n        'submission_path': str(submission_path),\n        'canonical_submission_path': str(canonical_submission),\n        'cv': cv_metrics,\n    })\n    write_text(results_path, json.dumps(payload, ensure_ascii=False, indent=2))\n    STAGE_RESULTS[stage_name] = payload\n    return payload\n\n\ndef load_stage(stage_name: str) -> dict[str, Any] | None:\n    if stage_name in STAGE_RESULTS:\n        return STAGE_RESULTS[stage_name]\n    path = stage_results_path(stage_name)\n    if not path.exists():\n        return None\n    payload = load_json(path)\n    STAGE_RESULTS[stage_name] = payload\n    return payload\n\n\ndef stage_row(stage_name: str) -> dict[str, Any] | None:\n    payload = load_stage(stage_name)\n    if payload is None:\n        return None\n    cv = payload.get('cv') or {}\n    required = ('map50', 'map50_95', 'kaggle_score')\n    if any(cv.get(key) is None for key in required):\n        return None\n    return {\n        'experiment': stage_name,\n        'summary': STAGES[stage_name]['summary'],\n        'model': payload['model'],\n        'split': payload['split_strategy'],\n        'map50': float(cv['map50']),\n        'map50_95': float(cv['map50_95']),\n        'kaggle_cv': float(cv['kaggle_score']),\n        'public_lb': LB_SCORES[stage_name]['public_lb'],\n        'private_lb': LB_SCORES[stage_name]['private_lb'],\n    }\n\n\ndef stage_df(stage_name: str) -> pd.DataFrame:\n    row = stage_row(stage_name)\n    columns = ['experiment', 'summary', 'model', 'split', 'map50', 'map50_95', 'kaggle_cv', 'public_lb', 'private_lb']\n    return pd.DataFrame([row], columns=columns).round(4) if row is not None else pd.DataFrame(columns=columns)\n\n\ndef gap_row(stage_name: str) -> dict[str, Any] | None:\n    row = stage_row(stage_name)\n    if row is None:\n        return None\n    return {\n        'experiment': stage_name,\n        'public_minus_kaggle_cv': row['public_lb'] - row['kaggle_cv'],\n        'private_minus_kaggle_cv': row['private_lb'] - row['kaggle_cv'],\n    }\n\n\ndef gap_df(stage_name: str) -> pd.DataFrame:\n    row = gap_row(stage_name)\n    columns = ['experiment', 'public_minus_kaggle_cv', 'private_minus_kaggle_cv']\n    return pd.DataFrame([row], columns=columns).round(4) if row is not None else pd.DataFrame(columns=columns)\n\n\ndef delta_row(stage_name: str, base_stage: str = 'Skeleton') -> dict[str, Any] | None:\n    base = stage_row(base_stage)\n    current = stage_row(stage_name)\n    if base is None or current is None:\n        return None\n    return {\n        'experiment': stage_name,\n        'delta_map50': current['map50'] - base['map50'],\n        'delta_map50_95': current['map50_95'] - base['map50_95'],\n        'delta_kaggle_cv': current['kaggle_cv'] - base['kaggle_cv'],\n        'delta_public_lb': current['public_lb'] - base['public_lb'],\n        'delta_private_lb': current['private_lb'] - base['private_lb'],\n    }\n\n\ndef delta_df(stage_name: str, base_stage: str = 'Skeleton') -> pd.DataFrame:\n    row = delta_row(stage_name, base_stage=base_stage)\n    columns = ['experiment', 'delta_map50', 'delta_map50_95', 'delta_kaggle_cv', 'delta_public_lb', 'delta_private_lb']\n    return pd.DataFrame([row], columns=columns).round(4) if row is not None else pd.DataFrame(columns=columns)\n\n\ndef available_rows(stage_names: list[str] | tuple[str, ...] | None = None) -> list[dict[str, Any]]:\n    names = stage_names if stage_names is not None else list(STAGES)\n    rows = []\n    for stage_name in names:\n        row = stage_row(stage_name)\n        if row is not None:\n            rows.append(row)\n    return rows\n\n\n_, _, _, IMAGE_META = load_boxes(TRAIN_CSV, TRAIN_IMAGES_DIR)\nOVERALL_SOURCE = IMAGE_META['source'].value_counts(normalize=True).sort_index()\n\n\ndef summarize_split(stage_name: str) -> tuple[pd.DataFrame, dict[str, Any]] | tuple[None, None]:\n    split_path = stage_split_path(stage_name)\n    if not split_path.exists():\n        return None, None\n    split = load_json(split_path)\n    val_meta = IMAGE_META.set_index('image_id').loc[split['val_ids']].reset_index()\n    source_dist = val_meta['source'].value_counts(normalize=True).sort_index().reindex(OVERALL_SOURCE.index, fill_value=0.0)\n    source_drift = (source_dist - OVERALL_SOURCE).abs()\n    details = pd.DataFrame({\n        'source': OVERALL_SOURCE.index,\n        'all_data': OVERALL_SOURCE.values,\n        'validation': source_dist.values,\n    })\n    summary = {\n        'val_count': len(split['val_ids']),\n        'mean_boxes': float(val_meta['box_count'].mean()),\n        'mean_abs_source_drift': float(source_drift.mean()),\n        'max_source_drift': float(source_drift.max()),\n    }\n    return details, summary\n\n\ndef split_tables() -> tuple[pd.DataFrame, pd.DataFrame]:\n    skeleton_source_df, skeleton_summary = summarize_split('Skeleton')\n    h0_source_df, h0_summary = summarize_split('Hypothesis 0')\n    if skeleton_source_df is None or h0_source_df is None:\n        return (\n            pd.DataFrame(columns=['split', 'val_count', 'mean_boxes', 'mean_abs_source_drift', 'max_source_drift']),\n            pd.DataFrame(columns=['source', 'all_data', 'random_val', 'stratified_val']),\n        )\n\n    split_quality_df = pd.DataFrame([\n        {\n            'split': 'all_data',\n            'val_count': len(IMAGE_META),\n            'mean_boxes': float(IMAGE_META['box_count'].mean()),\n            'mean_abs_source_drift': 0.0,\n            'max_source_drift': 0.0,\n        },\n        {'split': 'skeleton_random', **skeleton_summary},\n        {'split': 'hypothesis_0_source_box', **h0_summary},\n    ])\n    source_distribution_df = skeleton_source_df.rename(columns={'validation': 'random_val'}).merge(\n        h0_source_df[['source', 'validation']].rename(columns={'validation': 'stratified_val'}),\n        on='source',\n    )\n    return split_quality_df, source_distribution_df\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-11T08:12:29.196117Z","iopub.execute_input":"2026-03-11T08:12:29.196789Z","iopub.status.idle":"2026-03-11T08:12:59.686545Z","shell.execute_reply.started":"2026-03-11T08:12:29.196757Z","shell.execute_reply":"2026-03-11T08:12:59.685662Z"}},"outputs":[],"execution_count":null},{"id":"cc6cec43-1642-41b5-9d90-772db5b2f9f1","cell_type":"markdown","source":"## Скелет\n\n`YOLO11n + простой hold-out split + первый submit`\n\nЧто делаем:\n- берем маленькую современную модель\n- обучаем базовый pipeline\n- считаем локальную CV\n- сравниваем `local kaggle_score` с historical LB\n","metadata":{}},{"id":"78b1aca1-88b9-4035-ad52-3f96d938a821","cell_type":"code","source":"run_stage('Skeleton', retrain=FORCE_RETRAIN)\n\nskeleton_df = stage_df('Skeleton')\nskeleton_gap_df = gap_df('Skeleton')\ndisplay(skeleton_df)\ndisplay(skeleton_gap_df)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-11T08:12:59.688331Z","iopub.execute_input":"2026-03-11T08:12:59.688796Z","iopub.status.idle":"2026-03-11T08:17:36.030107Z","shell.execute_reply.started":"2026-03-11T08:12:59.688767Z","shell.execute_reply":"2026-03-11T08:17:36.029373Z"}},"outputs":[],"execution_count":null},{"id":"7c05f71a-3f15-45e4-a890-9c0e0c2ec204","cell_type":"markdown","source":"## Гипотеза 0\n\n`Улучшаем validation strategy`\n\nЧто делаем:\n- не random split, а более аккуратный split\n- сохраняем доли `source`\n- дополнительно стратифицируем по числу боксов на изображение\n\nЧто проверяем:\n- станет ли CV ближе к LB\n- уменьшится ли разрыв\n- станет ли validation более репрезентативной\n","metadata":{}},{"id":"2b6292a4-cd8a-44bf-a029-846c1e340cf6","cell_type":"code","source":"run_stage('Hypothesis 0', retrain=FORCE_RETRAIN)\n\nsplit_quality_df, source_distribution_df = split_tables()\nhypothesis_0_df = stage_df('Hypothesis 0')\nhypothesis_0_delta_df = delta_df('Hypothesis 0')\nhypothesis_0_gap_df = gap_df('Hypothesis 0')\n\ndisplay(split_quality_df.round(4))\ndisplay(source_distribution_df.round(4))\ndisplay(hypothesis_0_df)\ndisplay(hypothesis_0_delta_df)\ndisplay(hypothesis_0_gap_df)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-11T08:17:36.031510Z","iopub.execute_input":"2026-03-11T08:17:36.032108Z","iopub.status.idle":"2026-03-11T08:21:44.572955Z","shell.execute_reply.started":"2026-03-11T08:17:36.032079Z","shell.execute_reply":"2026-03-11T08:21:44.572190Z"}},"outputs":[],"execution_count":null},{"id":"cf66149b-387d-4874-85fc-38e7e23f8697","cell_type":"markdown","source":"## Гипотеза 1\n\n`Усиливаем модель: YOLO11n -> YOLO11s`\n\nЧто делаем:\n- оставляем тот же pipeline\n- оставляем тот же baseline split\n- меняем только модель на более сильную\n\nЧто проверяем:\n- растет ли CV\n- растет ли вместе с ним historical LB\n","metadata":{}},{"id":"6ede7301-1f3c-4135-aa89-e8a0b45e072d","cell_type":"code","source":"run_stage('Hypothesis 1', retrain=FORCE_RETRAIN)\n\nhypothesis_1_df = stage_df('Hypothesis 1')\nhypothesis_1_delta_df = delta_df('Hypothesis 1')\nhypothesis_1_gap_df = gap_df('Hypothesis 1')\n\ndisplay(hypothesis_1_df)\ndisplay(hypothesis_1_delta_df)\ndisplay(hypothesis_1_gap_df)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-11T08:21:44.574047Z","iopub.execute_input":"2026-03-11T08:21:44.574282Z","iopub.status.idle":"2026-03-11T08:30:27.873179Z","shell.execute_reply.started":"2026-03-11T08:21:44.574259Z","shell.execute_reply":"2026-03-11T08:30:27.872480Z"}},"outputs":[],"execution_count":null},{"id":"36ea77a2-ae82-4d42-bcb7-00a5c78e841e","cell_type":"markdown","source":"## Мои выводы по задаче\n\n- `Skeleton` дал рабочую базовую точку: `local kaggle_score = 0.5593`, `public LB = 0.5643`, `private LB = 0.4602`.\n- `Hypothesis 0` оказалась полезнее, чем казалось по одной локальной CV. Хотя `local kaggle_score` снизился до `0.5452`, leaderboard вырос: `public LB = 0.5684`, `private LB = 0.4790`. Значит, более аккуратный split действительно помог лучше оценивать и выбирать решение.\n- `Hypothesis 1` осталась лучшей конфигурацией из трех. Замена `YOLO11n -> YOLO11s` дала максимум и локально, и на leaderboard: `local kaggle_score = 0.6008`, `public LB = 0.6136`, `private LB = 0.4968`.\n- Главный вывод: в этой задаче сработали оба улучшения. Более аккуратный split улучшил реальный leaderboard относительно baseline, а более сильная модель дала лучший итоговый результат.\n\nИдеи для новых шагов:\n- попробовать cross-validation по `source`, чтобы стабильнее оценивать качество локально\n- отдельно потюнить `pred_conf`, NMS и TTA для submit\n- проверить более длинное обучение, больший `imgsz` и более сильные YOLO-конфигурации\n","metadata":{}},{"id":"8702126e-2b9f-4011-b5f7-7f8580b160c9","cell_type":"code","source":"summary_df = pd.DataFrame(available_rows()).round(4)\ngaps_df = pd.DataFrame(\n    [row for row in (gap_row(stage_name) for stage_name in STAGES) if row is not None]\n).round(4)\ndeltas_df = pd.DataFrame(\n    [row for row in (delta_row(stage_name) for stage_name in ('Hypothesis 0', 'Hypothesis 1')) if row is not None]\n).round(4)\nhistorical_lb_df = pd.DataFrame([\n    {\n        'experiment': stage_name,\n        'public_lb': LB_SCORES[stage_name]['public_lb'],\n        'private_lb': LB_SCORES[stage_name]['private_lb'],\n    }\n    for stage_name in STAGES\n]).sort_values(['public_lb', 'private_lb'], ascending=False).reset_index(drop=True)\n\ndisplay(historical_lb_df)\ndisplay(summary_df)\ndisplay(gaps_df)\ndisplay(deltas_df)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-11T08:30:27.875020Z","iopub.execute_input":"2026-03-11T08:30:27.875584Z","iopub.status.idle":"2026-03-11T08:30:27.910864Z","shell.execute_reply.started":"2026-03-11T08:30:27.875554Z","shell.execute_reply":"2026-03-11T08:30:27.910121Z"}},"outputs":[],"execution_count":null}]}