From 4e76326c4f37dd1fa16ffab9ec519b4974446584 Mon Sep 17 00:00:00 2001 From: pikaliov Date: Tue, 7 Jul 2026 19:34:52 +0300 Subject: [PATCH] Full fix --- .gitignore | 2 + src/data/gta_uav.py | 64 ++++++++++++--- src/data/gta_uav_eval.py | 2 +- src/data/mutually_exclusive_sampler.py | 108 +++++++++++++++++++++++++ train.py | 9 ++- 5 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 src/data/mutually_exclusive_sampler.py diff --git a/.gitignore b/.gitignore index 3952fb7..366d232 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ cache/ backtranslate.py debug_augmentation.py text_augmentation.py +smoke_train.sh +*.md __pycache__/ *.py[cod] diff --git a/src/data/gta_uav.py b/src/data/gta_uav.py index e37fad1..59ba9fa 100644 --- a/src/data/gta_uav.py +++ b/src/data/gta_uav.py @@ -167,7 +167,7 @@ def combine_text_levels( # --------------------------------------------------------------------------- def get_image_transforms( - image_size: int = 384, + image_size: int = 256, is_train: bool = True, ) -> transforms.Compose: """Трансформации для изображений (используются и для drone, и для satellite). @@ -230,7 +230,7 @@ class GTAUAVTextDataset(Dataset): pairs_meta_file: str, descriptions_path: str | Path, text_levels: list[str] = ("level1",), - image_size: int = 384, + image_size: int = 256, is_train: bool = True, use_semipos: bool = True, context_length: int = 248, @@ -392,14 +392,21 @@ def build_dataloaders( test_meta: str = "cross-area-drone2sate-test.json", batch_size: int = 64, num_workers: int = 4, - image_size: int = 384, + image_size: int = 256, build_test: bool = True, + mutually_exclusive: bool = True, + seed: int = 42, ) -> tuple[DataLoader, DataLoader | None]: """Создать train и (опционально) диагональный test DataLoader. build_test=False — не строить test-набор (оценка идёт через multi-positive eval из gta_uav_eval, который сам грузит галерею и описания; иначе описания читаются дважды). + + mutually_exclusive=True — формировать train-батчи так, чтобы в одном + батче не было двух дронов с общим позитивным тайлом (протокол §6.4); + убирает false-negatives на диагонали InfoNCE. seed синхронизирует + порядок с train seed. False → обычный random shuffle. """ train_dataset = GTAUAVTextDataset( data_root=data_root, @@ -411,15 +418,48 @@ def build_dataloaders( use_semipos=True, ) - train_loader = DataLoader( - train_dataset, - batch_size=batch_size, - shuffle=True, - num_workers=num_workers, - collate_fn=collate_fn, - pin_memory=True, - drop_last=True, - ) + if mutually_exclusive: + from src.data.mutually_exclusive_sampler import MutuallyExclusiveBatchSampler + + # Позитивные тайлы (pos + semi-pos) каждого дрона → множества int-id. + tile_to_id: dict[str, int] = {} + positive_sets: list[set[int]] = [] + for entry in train_dataset.pairs_meta: + names = ( + entry.get("pair_pos_semipos_sate_img_list") + or entry.get("pair_pos_sate_img_list") + or [] + ) + ids = set() + for name in names: + ids.add(tile_to_id.setdefault(name, len(tile_to_id))) + positive_sets.append(ids) + + batch_sampler = MutuallyExclusiveBatchSampler( + positive_sets, batch_size=batch_size, seed=seed, drop_last=True, + ) + train_loader = DataLoader( + train_dataset, + batch_sampler=batch_sampler, + num_workers=num_workers, + collate_fn=collate_fn, + pin_memory=True, + ) + LOGGER.info( + "🧩 Mutually-exclusive sampling ON (seed=%d): нет общих позитивных " + "тайлов внутри батча (§6.4)", seed, + ) + else: + train_loader = DataLoader( + train_dataset, + batch_size=batch_size, + shuffle=True, + num_workers=num_workers, + collate_fn=collate_fn, + pin_memory=True, + drop_last=True, + ) + LOGGER.info("🔀 Random shuffle sampling (mutually_exclusive=False)") if not build_test: LOGGER.info( diff --git a/src/data/gta_uav_eval.py b/src/data/gta_uav_eval.py index cb6ab7d..bf86af2 100644 --- a/src/data/gta_uav_eval.py +++ b/src/data/gta_uav_eval.py @@ -176,7 +176,7 @@ def build_multipos_eval( test_meta: str, descriptions_path: str | Path, text_levels: list[str], - image_size: int = 384, + image_size: int = 256, batch_size: int = 64, num_workers: int = 4, context_length: int = 248, diff --git a/src/data/mutually_exclusive_sampler.py b/src/data/mutually_exclusive_sampler.py new file mode 100644 index 0000000..15c1e75 --- /dev/null +++ b/src/data/mutually_exclusive_sampler.py @@ -0,0 +1,108 @@ +"""Mutually-exclusive batch sampler для diagonal InfoNCE (протокол §6.4). + +Проблема, которую решает (см. EXPERIMENT_SETUP.md §8): + Loss — диагональный InfoNCE: в батче B пар, правильные на диагонали, всё + вне диагонали считается негативами. На GTA-UAV один спутниковый тайл часто + накрывает несколько дронов (avg 4.58 semi-позитивов). При случайном shuffle + два дрона с ОБЩИМ позитивным тайлом попадают в один батч → этот тайл для + одного дрона стоит вне диагонали и ошибочно штрафуется как негатив + (false-negative), зашумляя градиент. + +Решение: + Формируем батчи так, чтобы множества позитивных тайлов любых двух дронов + в одном батче НЕ пересекались. Тогда, какой бы тайл ни был сэмплирован на + диагональ, в батче нет второго дрона, для которого он позитив. + +Гарантия консервативная (по ПОЛНЫМ спискам pos+semi-pos), поэтому от выбора +конкретного тайла в __getitem__ результат не зависит. + +Порядок воспроизводим: перестановка зависит только от seed и номера эпохи +(инкрементируется на каждый __iter__), что согласуется с set_seed в train.py. +""" +from __future__ import annotations + +import logging + +import torch +from torch.utils.data import Sampler + +LOGGER = logging.getLogger("cvgl.data.me_sampler") + + +class MutuallyExclusiveBatchSampler(Sampler[list[int]]): + """Batch-sampler: в одном батче нет двух элементов с общим позитивным тайлом. + + Жадное bin-packing first-fit: элементы обходятся в случайном (по seed+epoch) + порядке; каждый кладётся в первый открытый батч, чьи уже занятые тайлы не + пересекаются с его позитивами и который ещё не заполнен, иначе открывается + новый батч. Заполненные до batch_size батчи отдаются наружу. + + Args: + positive_sets: Для каждого элемента датасета — множество id позитивных + тайлов (int). Пустое множество допустимо (элемент совместим с любым + батчом). + batch_size: Размер батча. + seed: База для генератора перестановок (совпадает с train seed). + drop_last: Отбрасывать ли неполные батчи (True — как в обучении). + """ + + def __init__( + self, + positive_sets: list[set[int]], + batch_size: int, + seed: int = 42, + drop_last: bool = True, + ) -> None: + self.positive_sets = [frozenset(s) for s in positive_sets] + self.batch_size = batch_size + self.seed = seed + self.drop_last = drop_last + self.n = len(self.positive_sets) + self._epoch = 0 + + def set_epoch(self, epoch: int) -> None: + """Явно задать номер эпохи (опционально; иначе инкремент на __iter__).""" + self._epoch = epoch + + def __iter__(self): + g = torch.Generator() + g.manual_seed(self.seed + self._epoch) + order = torch.randperm(self.n, generator=g).tolist() + self._epoch += 1 + + open_batches: list[dict] = [] # [{"members": [...], "used": set()}] + n_yielded = 0 + + for idx in order: + pos = self.positive_sets[idx] + placed = False + for b in open_batches: + if len(b["members"]) < self.batch_size and b["used"].isdisjoint(pos): + b["members"].append(idx) + b["used"] |= pos + placed = True + if len(b["members"]) == self.batch_size: + open_batches.remove(b) + n_yielded += 1 + yield b["members"] + break + if not placed: + open_batches.append({"members": [idx], "used": set(pos)}) + + # Хвост: неполные батчи. + for b in open_batches: + if not self.drop_last: + n_yielded += 1 + yield b["members"] + + LOGGER.debug( + "MutuallyExclusiveBatchSampler: %d батчей выдано (n=%d, bs=%d, drop_last=%s)", + n_yielded, self.n, self.batch_size, self.drop_last, + ) + + def __len__(self) -> int: + # Приблизительно: точное число батчей зависит от коллизий и известно + # только после прохода. Для drop_last=True это верхняя оценка. + if self.drop_last: + return self.n // self.batch_size + return (self.n + self.batch_size - 1) // self.batch_size diff --git a/train.py b/train.py index b829354..5e5fe7e 100644 --- a/train.py +++ b/train.py @@ -292,6 +292,8 @@ def main(args): num_workers=args.num_workers, image_size=args.image_size, build_test=False, # оценка идёт через multi-positive eval ниже + mutually_exclusive=args.mutually_exclusive, + seed=args.seed, ) # Multi-positive eval (протокол §6.2): уникальная галерея + positive-карты. @@ -485,7 +487,7 @@ def parse_args(): p.add_argument("--text_levels", nargs="+", default=["level1"]) p.add_argument("--train_meta", default="cross-area-drone2sate-train.json") p.add_argument("--test_meta", default="cross-area-drone2sate-test.json") - p.add_argument("--image_size", type=int, default=384) + p.add_argument("--image_size", type=int, default=256) p.add_argument("--num_workers", type=int, default=8) # Model @@ -496,6 +498,11 @@ def parse_args(): p.add_argument("--shared_dim", type=int, default=512) p.add_argument("--inject_mona", action="store_true", default=True) p.add_argument("--mona_bottleneck", type=int, default=64) + p.add_argument( + "--mutually_exclusive", action=argparse.BooleanOptionalAction, default=True, + help="Батчи без общих позитивных тайлов (§6.4); --no-mutually_exclusive " + "→ обычный random shuffle.", + ) # Training p.add_argument("--epochs", type=int, default=50)