File size: 10,110 Bytes
34f3bc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""π0-style volume-weighted multi-dataset mixing.

Implements the processor-granularity n^0.43 formula from the π0 paper
(Section V-A: "we weight each task-robot combination by n^0.43").

Unlike MultiLeRobotDataset (concat-by-length) or MultiStreamingLeRobotDataset
(IterableDataset), this is a map-style Dataset that performs weighted random
adapter selection per __getitem__. Each sub-dataset is assumed to be a
TransformedAdapterDataset around a LeRobot adapter — its __getitem__ returns
a transform-ready frame dict.

Caller contract:
  - `datasets`: list of map-style Datasets with a valid __len__
  - `weights`: non-negative floats, one per dataset; normalized internally
  - `repo_ids`: optional list[str] for logging
  - `seed`: base seed for deterministic sampling (default 42)

Sampling is deterministic w.r.t. (epoch, idx): per-call seed =
numpy.SeedSequence([seed, epoch, idx]) -> default_rng. (The Python `random`
module is non-deterministic across ranks/resume and leaks state across
DataLoader workers.) Use `set_epoch(epoch)` (DistributedSampler convention) to
advance the seed each epoch. `__len__` returns the sum of per-dataset lengths so
DataLoader sizing is unchanged.
"""
from __future__ import annotations

import logging
from typing import Iterator, Sequence

import numpy as np
from torch.utils.data import Dataset, Sampler

logger = logging.getLogger(__name__)


class PI0MixtureDataset(Dataset):
    """Volume-weighted mixture of map-style Datasets."""

    def __init__(
        self,
        datasets: Sequence[Dataset],
        weights: Sequence[float],
        repo_ids: Sequence[str] | None = None,
        seed: int = 42,
    ) -> None:
        if len(datasets) != len(weights):
            raise ValueError(
                f"PI0MixtureDataset: len(datasets)={len(datasets)} != "
                f"len(weights)={len(weights)}"
            )
        if not datasets:
            raise ValueError("PI0MixtureDataset: requires at least one dataset")
        self._datasets = list(datasets)
        self._lengths = [len(d) for d in self._datasets]
        self._total_len = sum(self._lengths)

        # Normalize weights defensively (caller should have normalized).
        weights_arr = np.asarray([float(w) for w in weights], dtype=np.float64)
        if (weights_arr < 0).any():
            raise ValueError(f"PI0MixtureDataset: weights must be non-negative, got {weights_arr.tolist()}")
        total = float(weights_arr.sum())
        if total <= 0:
            raise ValueError(f"PI0MixtureDataset: sum(weights)={total} <= 0")
        self._weights = weights_arr / total

        # Reject zero-length sub-datasets with positive weight. Both __getitem__
        # and PI0MixtureBatchSampler call rng.integers(0, len(ds)), which raises
        # "low >= high" the moment such a dataset is sampled; fail loud at
        # construction instead of mid-training. Zero-length with zero weight is
        # harmless (rng.choice with p=0 never selects it) and allowed.
        empty_weighted = [
            (repo_ids[i] if repo_ids is not None else f"dataset[{i}]")
            for i in range(len(self._datasets))
            if self._lengths[i] == 0 and self._weights[i] > 0
        ]
        if empty_weighted:
            raise ValueError(
                f"PI0MixtureDataset: zero-length sub-dataset(s) with positive "
                f"weight: {empty_weighted}. Drop these datasets (and renormalize "
                f"weights) or set their weight to 0 before constructing the "
                f"mixture — otherwise sampling one raises 'low >= high'."
            )

        self._repo_ids = list(repo_ids) if repo_ids is not None else [
            f"dataset[{i}]" for i in range(len(self._datasets))
        ]

        # Deterministic RNG state.
        self._base_seed = int(seed)
        self._epoch = 0

        pairs = ", ".join(
            f"{r}={w:.3f}" for r, w in zip(self._repo_ids, self._weights)
        )
        logger.info(f"[π0-mix] weights: [{pairs}]")
        logger.info(
            f"[π0-mix] total_len={self._total_len:,} "
            f"({[l for l in self._lengths]} frames per dataset) "
            f"seed={self._base_seed}"
        )

    def set_epoch(self, epoch: int) -> None:
        """Advance the deterministic-sampling epoch.

        Mirrors ``torch.utils.data.distributed.DistributedSampler.set_epoch``
        — call before each new epoch so that per-(epoch, idx) RNG seeds
        differ across epochs while still being identical across ranks for
        the same epoch.
        """
        self._epoch = int(epoch)

    def __len__(self) -> int:
        return self._total_len

    def __getitem__(self, idx: int | tuple[int, int]) -> dict:
        if isinstance(idx, tuple):
            dataset_index, local_index = idx
            dataset_index = int(dataset_index)
            local_index = int(local_index)
            if not 0 <= dataset_index < len(self._datasets):
                raise IndexError(
                    f"PI0MixtureDataset direct dataset index {dataset_index} "
                    f"out of range [0, {len(self._datasets)})"
                )
            if not 0 <= local_index < self._lengths[dataset_index]:
                raise IndexError(
                    f"PI0MixtureDataset direct local index {local_index} "
                    f"out of range [0, {self._lengths[dataset_index]}) "
                    f"for dataset {dataset_index}"
                )
            return self._datasets[dataset_index][local_index]

        # Deterministic per-(base_seed, epoch, idx) RNG: two ranks asking the
        # same idx in the same epoch get the same sample, making resume
        # reproducible. The DataLoader index is a nominal counter (π0 convention)
        # used only as a seed component, not as direct selection content, so
        # different idx values still draw different samples within an epoch.
        ss = np.random.SeedSequence([self._base_seed, self._epoch, int(idx)])
        rng = np.random.default_rng(ss)
        ds_idx = int(rng.choice(len(self._datasets), p=self._weights))
        ds = self._datasets[ds_idx]
        local_idx = int(rng.integers(0, len(ds)))
        return ds[local_idx]

    # Expose per-dataset metadata for downstream logging / checkpointing.
    @property
    def num_frames(self) -> int:
        return self._total_len

    @property
    def num_episodes(self) -> int:
        total = 0
        for ds in self._datasets:
            n = getattr(ds, "num_episodes", None)
            if n is None:
                adapter = getattr(ds, "adapter", None)
                n = getattr(adapter, "num_episodes", None) if adapter is not None else None
            if n is not None:
                total += int(n)
        return total

    @property
    def weights(self) -> list[float]:
        return list(self._weights)

    @property
    def repo_ids(self) -> list[str]:
        return list(self._repo_ids)

    @property
    def epoch(self) -> int:
        return self._epoch

    @property
    def base_seed(self) -> int:
        return self._base_seed


class PI0MixtureBatchSampler(Sampler[list[tuple[int, int]]]):
    """Homogeneous mini-batches for ``PI0MixtureDataset``.

    Choose exactly one source dataset per mini-batch, then sample all local
    indices from that source. This preserves π0-style weighted mixture
    semantics at the batch level while preventing one heterogeneous collate
    from taking the union of every annotation/FAST field across unrelated
    dataset schemas.
    """

    def __init__(
        self,
        dataset: PI0MixtureDataset,
        batch_size: int,
        drop_last: bool = True,
        seed: int | None = None,
        start_batch_index: int = 0,
    ) -> None:
        if not isinstance(dataset, PI0MixtureDataset):
            raise TypeError(
                "PI0MixtureBatchSampler requires a PI0MixtureDataset, got "
                f"{type(dataset).__name__}"
            )
        if int(batch_size) <= 0:
            raise ValueError(f"batch_size must be positive, got {batch_size}")
        self.dataset = dataset
        self.batch_size = int(batch_size)
        self.drop_last = bool(drop_last)
        self._base_seed = dataset.base_seed if seed is None else int(seed)
        self.start_batch_index = max(0, int(start_batch_index))

    def set_start_batch_index(self, start_batch_index: int) -> None:
        self.start_batch_index = max(0, int(start_batch_index))

    def __len__(self) -> int:
        total = len(self.dataset)
        if self.drop_last:
            total_batches = total // self.batch_size
        else:
            total_batches = (total + self.batch_size - 1) // self.batch_size
        return max(0, total_batches - min(self.start_batch_index, total_batches))

    def __iter__(self) -> Iterator[list[tuple[int, int]]]:
        epoch = int(self.dataset.epoch)
        if self.drop_last:
            total_batches = len(self.dataset) // self.batch_size
        else:
            total_batches = (len(self.dataset) + self.batch_size - 1) // self.batch_size
        start = min(self.start_batch_index, total_batches)
        for batch_index in range(start, total_batches):
            current_batch_size = self.batch_size
            if not self.drop_last and batch_index == total_batches - 1:
                remainder = len(self.dataset) % self.batch_size
                if remainder:
                    current_batch_size = remainder

            seed_sequence = np.random.SeedSequence(
                [self._base_seed, epoch, int(batch_index), 271828]
            )
            rng = np.random.default_rng(seed_sequence)
            dataset_index = int(
                rng.choice(len(self.dataset._datasets), p=self.dataset._weights)
            )
            source_length = self.dataset._lengths[dataset_index]
            local_indices = rng.integers(
                0, source_length, size=current_batch_size
            )
            yield [
                (dataset_index, int(local_index))
                for local_index in local_indices
            ]