This commit is contained in:
pikaliov
2026-07-07 19:34:52 +03:00
parent 05355c6a7b
commit 4e76326c4f
5 changed files with 171 additions and 14 deletions

2
.gitignore vendored
View File

@@ -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]

View File

@@ -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(

View File

@@ -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,

View 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

View File

@@ -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)