vla / dovla_cil /data /group_sampler.py
anhtld's picture
Initial commit: DoVLA-CIL codebase (h=16 breakthrough)
adc02fa verified
Raw
History Blame Contribute Delete
9.03 kB
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Iterable, Iterator, Sequence
from dovla_cil.data.datasets import CILDataset
from dovla_cil.data.schema import CILRecord
from dovla_cil.data.sharding import group_records
class BatchIndices(list[int]):
"""Batch index list with same-state ranking metadata."""
def __init__(
self,
indices: Iterable[int],
*,
group_ids: Iterable[str] = (),
pair_indices: Iterable[tuple[int, int]] = (),
) -> None:
super().__init__(int(index) for index in indices)
self.group_ids = list(group_ids)
self.pair_indices = [tuple(pair) for pair in pair_indices]
class GroupAwareBatchSampler:
"""Yield batches that preserve CIL same-state group structure.
Modes:
- `full_group`: include complete groups, up to `batch_groups` groups per batch.
- `pairs`: sample positive/negative same-group pairs for ranking losses.
- `mixed`: sample `records_per_group` records per group while preferring reward-diverse sets.
"""
def __init__(
self,
dataset: CILDataset,
*,
mode: str = "full_group",
batch_groups: int = 1,
records_per_group: int | None = None,
pair_count_per_group: int = 1,
shuffle: bool = False,
seed: int = 17,
reward_margin: float = 1e-6,
group_ids: Sequence[str] | None = None,
) -> None:
if mode not in {"full_group", "pairs", "mixed"}:
raise ValueError("mode must be 'full_group', 'pairs', or 'mixed'")
if batch_groups <= 0:
raise ValueError("batch_groups must be positive")
if records_per_group is not None and records_per_group <= 0:
raise ValueError("records_per_group must be positive when provided")
if pair_count_per_group <= 0:
raise ValueError("pair_count_per_group must be positive")
if reward_margin < 0:
raise ValueError("reward_margin must be non-negative")
self.dataset = dataset
self.mode = mode
self.batch_groups = int(batch_groups)
self.records_per_group = records_per_group
self.pair_count_per_group = int(pair_count_per_group)
self.shuffle = bool(shuffle)
self.seed = int(seed)
self.reward_margin = float(reward_margin)
unknown = [group_id for group_id in (group_ids or []) if group_id not in dataset.group_ids]
if unknown:
raise KeyError(f"Unknown CIL groups for sampler: {unknown[:3]}")
self.group_ids = list(group_ids) if group_ids is not None else list(dataset.group_ids)
def __iter__(self) -> Iterator[BatchIndices]:
rng = random.Random(self.seed)
group_ids = list(self.group_ids)
if self.shuffle:
rng.shuffle(group_ids)
for offset in range(0, len(group_ids), self.batch_groups):
selected_group_ids = group_ids[offset : offset + self.batch_groups]
if self.mode == "full_group":
yield self._full_group_batch(selected_group_ids)
elif self.mode == "pairs":
yield self._pair_batch(selected_group_ids, rng)
else:
yield self._mixed_batch(selected_group_ids, rng)
def __len__(self) -> int:
if not self.group_ids:
return 0
return (len(self.group_ids) + self.batch_groups - 1) // self.batch_groups
def _full_group_batch(self, group_ids: Sequence[str]) -> BatchIndices:
indices: list[int] = []
for group_id in group_ids:
indices.extend(self.dataset.group_indices(group_id))
pair_indices = _local_pair_indices(
[self.dataset[index] for index in indices], reward_margin=self.reward_margin
)
return BatchIndices(indices, group_ids=group_ids, pair_indices=pair_indices)
def _pair_batch(self, group_ids: Sequence[str], rng: random.Random) -> BatchIndices:
indices: list[int] = []
pair_indices: list[tuple[int, int]] = []
for group_id in group_ids:
group_indices = self.dataset.group_indices(group_id)
records = [self.dataset[index] for index in group_indices]
global_pairs = _sample_reward_ordered_pairs(
group_indices,
records,
pair_count=self.pair_count_per_group,
reward_margin=self.reward_margin,
rng=rng,
)
local_by_global: dict[int, int] = {}
for better, worse in global_pairs:
if better not in local_by_global:
local_by_global[better] = len(indices)
indices.append(better)
if worse not in local_by_global:
local_by_global[worse] = len(indices)
indices.append(worse)
pair_indices.append((local_by_global[better], local_by_global[worse]))
return BatchIndices(indices, group_ids=group_ids, pair_indices=pair_indices)
def _mixed_batch(self, group_ids: Sequence[str], rng: random.Random) -> BatchIndices:
indices: list[int] = []
for group_id in group_ids:
group_indices = self.dataset.group_indices(group_id)
records = [self.dataset[index] for index in group_indices]
selected = _sample_records_from_group(
group_indices,
records,
count=self.records_per_group or len(group_indices),
reward_margin=self.reward_margin,
rng=rng,
)
indices.extend(selected)
pair_indices = _local_pair_indices(
[self.dataset[index] for index in indices], reward_margin=self.reward_margin
)
return BatchIndices(indices, group_ids=group_ids, pair_indices=pair_indices)
@dataclass(frozen=True)
class GroupBatchSampler:
"""Backward-compatible complete-group sampler over materialized records."""
records: tuple[CILRecord, ...]
shuffle: bool = False
seed: int = 17
@classmethod
def from_records(
cls, records: Iterable[CILRecord], *, shuffle: bool = False, seed: int = 17
) -> "GroupBatchSampler":
return cls(records=tuple(records), shuffle=shuffle, seed=seed)
def __iter__(self) -> Iterator[list[CILRecord]]:
groups = list(group_records(self.records).values())
if self.shuffle:
rng = random.Random(self.seed)
rng.shuffle(groups)
yield from groups
def _sample_reward_ordered_pairs(
group_indices: list[int],
records: list[CILRecord],
*,
pair_count: int,
reward_margin: float,
rng: random.Random,
) -> list[tuple[int, int]]:
candidates: list[tuple[int, int]] = []
for left in range(len(records)):
for right in range(len(records)):
if left == right:
continue
reward_left = _ranking_reward(records[left])
reward_right = _ranking_reward(records[right])
if reward_left > reward_right + reward_margin:
candidates.append((group_indices[left], group_indices[right]))
rng.shuffle(candidates)
return candidates[:pair_count]
def _sample_records_from_group(
group_indices: list[int],
records: list[CILRecord],
*,
count: int,
reward_margin: float,
rng: random.Random,
) -> list[int]:
if count >= len(group_indices):
return list(group_indices)
pairs = _sample_reward_ordered_pairs(
group_indices,
records,
pair_count=max(1, count // 2),
reward_margin=reward_margin,
rng=rng,
)
selected: list[int] = []
for better, worse in pairs:
for index in (better, worse):
if index not in selected:
selected.append(index)
if len(selected) >= count:
return selected
remaining = [index for index in group_indices if index not in selected]
rng.shuffle(remaining)
return selected + remaining[: max(0, count - len(selected))]
def _local_pair_indices(records: list[CILRecord], *, reward_margin: float) -> list[tuple[int, int]]:
pairs: list[tuple[int, int]] = []
by_group = group_records(records)
offset_by_record_id = {record.record_id: index for index, record in enumerate(records)}
for group in by_group.values():
for left in range(len(group)):
for right in range(len(group)):
if left == right:
continue
if _ranking_reward(group[left]) > _ranking_reward(group[right]) + reward_margin:
pairs.append(
(
offset_by_record_id[group[left].record_id],
offset_by_record_id[group[right].record_id],
)
)
return pairs
def _ranking_reward(record: CILRecord) -> float:
return record.reward.score