Full fix
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@ cache/
|
||||
backtranslate.py
|
||||
debug_augmentation.py
|
||||
text_augmentation.py
|
||||
smoke_train.sh
|
||||
*.md
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -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,6 +418,38 @@ def build_dataloaders(
|
||||
use_semipos=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,
|
||||
@@ -420,6 +459,7 @@ def build_dataloaders(
|
||||
pin_memory=True,
|
||||
drop_last=True,
|
||||
)
|
||||
LOGGER.info("🔀 Random shuffle sampling (mutually_exclusive=False)")
|
||||
|
||||
if not build_test:
|
||||
LOGGER.info(
|
||||
|
||||
@@ -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,
|
||||
|
||||
108
src/data/mutually_exclusive_sampler.py
Normal file
108
src/data/mutually_exclusive_sampler.py
Normal file
@@ -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
|
||||
9
train.py
9
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)
|
||||
|
||||
Reference in New Issue
Block a user