Full fix
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@ cache/
|
|||||||
backtranslate.py
|
backtranslate.py
|
||||||
debug_augmentation.py
|
debug_augmentation.py
|
||||||
text_augmentation.py
|
text_augmentation.py
|
||||||
|
smoke_train.sh
|
||||||
|
*.md
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ def combine_text_levels(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_image_transforms(
|
def get_image_transforms(
|
||||||
image_size: int = 384,
|
image_size: int = 256,
|
||||||
is_train: bool = True,
|
is_train: bool = True,
|
||||||
) -> transforms.Compose:
|
) -> transforms.Compose:
|
||||||
"""Трансформации для изображений (используются и для drone, и для satellite).
|
"""Трансформации для изображений (используются и для drone, и для satellite).
|
||||||
@@ -230,7 +230,7 @@ class GTAUAVTextDataset(Dataset):
|
|||||||
pairs_meta_file: str,
|
pairs_meta_file: str,
|
||||||
descriptions_path: str | Path,
|
descriptions_path: str | Path,
|
||||||
text_levels: list[str] = ("level1",),
|
text_levels: list[str] = ("level1",),
|
||||||
image_size: int = 384,
|
image_size: int = 256,
|
||||||
is_train: bool = True,
|
is_train: bool = True,
|
||||||
use_semipos: bool = True,
|
use_semipos: bool = True,
|
||||||
context_length: int = 248,
|
context_length: int = 248,
|
||||||
@@ -392,14 +392,21 @@ def build_dataloaders(
|
|||||||
test_meta: str = "cross-area-drone2sate-test.json",
|
test_meta: str = "cross-area-drone2sate-test.json",
|
||||||
batch_size: int = 64,
|
batch_size: int = 64,
|
||||||
num_workers: int = 4,
|
num_workers: int = 4,
|
||||||
image_size: int = 384,
|
image_size: int = 256,
|
||||||
build_test: bool = True,
|
build_test: bool = True,
|
||||||
|
mutually_exclusive: bool = True,
|
||||||
|
seed: int = 42,
|
||||||
) -> tuple[DataLoader, DataLoader | None]:
|
) -> tuple[DataLoader, DataLoader | None]:
|
||||||
"""Создать train и (опционально) диагональный test DataLoader.
|
"""Создать train и (опционально) диагональный test DataLoader.
|
||||||
|
|
||||||
build_test=False — не строить test-набор (оценка идёт через
|
build_test=False — не строить test-набор (оценка идёт через
|
||||||
multi-positive eval из gta_uav_eval, который сам грузит галерею и
|
multi-positive eval из gta_uav_eval, который сам грузит галерею и
|
||||||
описания; иначе описания читаются дважды).
|
описания; иначе описания читаются дважды).
|
||||||
|
|
||||||
|
mutually_exclusive=True — формировать train-батчи так, чтобы в одном
|
||||||
|
батче не было двух дронов с общим позитивным тайлом (протокол §6.4);
|
||||||
|
убирает false-negatives на диагонали InfoNCE. seed синхронизирует
|
||||||
|
порядок с train seed. False → обычный random shuffle.
|
||||||
"""
|
"""
|
||||||
train_dataset = GTAUAVTextDataset(
|
train_dataset = GTAUAVTextDataset(
|
||||||
data_root=data_root,
|
data_root=data_root,
|
||||||
@@ -411,15 +418,48 @@ def build_dataloaders(
|
|||||||
use_semipos=True,
|
use_semipos=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
train_loader = DataLoader(
|
if mutually_exclusive:
|
||||||
train_dataset,
|
from src.data.mutually_exclusive_sampler import MutuallyExclusiveBatchSampler
|
||||||
batch_size=batch_size,
|
|
||||||
shuffle=True,
|
# Позитивные тайлы (pos + semi-pos) каждого дрона → множества int-id.
|
||||||
num_workers=num_workers,
|
tile_to_id: dict[str, int] = {}
|
||||||
collate_fn=collate_fn,
|
positive_sets: list[set[int]] = []
|
||||||
pin_memory=True,
|
for entry in train_dataset.pairs_meta:
|
||||||
drop_last=True,
|
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:
|
if not build_test:
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def build_multipos_eval(
|
|||||||
test_meta: str,
|
test_meta: str,
|
||||||
descriptions_path: str | Path,
|
descriptions_path: str | Path,
|
||||||
text_levels: list[str],
|
text_levels: list[str],
|
||||||
image_size: int = 384,
|
image_size: int = 256,
|
||||||
batch_size: int = 64,
|
batch_size: int = 64,
|
||||||
num_workers: int = 4,
|
num_workers: int = 4,
|
||||||
context_length: int = 248,
|
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,
|
num_workers=args.num_workers,
|
||||||
image_size=args.image_size,
|
image_size=args.image_size,
|
||||||
build_test=False, # оценка идёт через multi-positive eval ниже
|
build_test=False, # оценка идёт через multi-positive eval ниже
|
||||||
|
mutually_exclusive=args.mutually_exclusive,
|
||||||
|
seed=args.seed,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Multi-positive eval (протокол §6.2): уникальная галерея + positive-карты.
|
# Multi-positive eval (протокол §6.2): уникальная галерея + positive-карты.
|
||||||
@@ -485,7 +487,7 @@ def parse_args():
|
|||||||
p.add_argument("--text_levels", nargs="+", default=["level1"])
|
p.add_argument("--text_levels", nargs="+", default=["level1"])
|
||||||
p.add_argument("--train_meta", default="cross-area-drone2sate-train.json")
|
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("--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)
|
p.add_argument("--num_workers", type=int, default=8)
|
||||||
|
|
||||||
# Model
|
# Model
|
||||||
@@ -496,6 +498,11 @@ def parse_args():
|
|||||||
p.add_argument("--shared_dim", type=int, default=512)
|
p.add_argument("--shared_dim", type=int, default=512)
|
||||||
p.add_argument("--inject_mona", action="store_true", default=True)
|
p.add_argument("--inject_mona", action="store_true", default=True)
|
||||||
p.add_argument("--mona_bottleneck", type=int, default=64)
|
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
|
# Training
|
||||||
p.add_argument("--epochs", type=int, default=50)
|
p.add_argument("--epochs", type=int, default=50)
|
||||||
|
|||||||
Reference in New Issue
Block a user