Skip to content

Commit 6477e4a

Browse files
theap06claude
andcommitted
[Feature] Offline-to-online transition utilities for replay buffers
Adds ergonomic wrappers for the pretrain-offline → fine-tune-online pattern: - OfflineToOnlineReplayBuffer: combines an immutable offline dataset with a growing online buffer. extend() routes new experience to the online buffer; sample() returns a flat [batch_size] TensorDict with a deterministic round(offline_fraction * batch_size) offline / remainder online split, so the ratio is honored on every call. anneal() linearly decays the offline fraction to zero over training. Falls back to offline-only sampling when the online buffer is empty. - prefill_replay_buffer: copies samples from an offline dataset into a mutable buffer for the simpler single-flat-buffer pattern. - load_dataset: parses "minari:<id>" / "d4rl:<id>" strings into the matching dataset object. Includes test/test_offline_to_online.py (30 tests, no Minari/D4RL required). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 92a3f7f commit 6477e4a

6 files changed

Lines changed: 680 additions & 0 deletions

File tree

test/test_offline_to_online.py

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
from __future__ import annotations
6+
7+
import argparse
8+
9+
import pytest
10+
import torch
11+
from tensordict import TensorDict
12+
13+
from torchrl.data import LazyTensorStorage, OfflineToOnlineReplayBuffer, ReplayBuffer
14+
from torchrl.data.datasets.utils import load_dataset
15+
from torchrl.data.replay_buffers.offline_to_online import prefill_replay_buffer
16+
17+
18+
def _make_offline_buffer(n: int = 1000, obs_dim: int = 4, action_dim: int = 2):
19+
"""A plain ReplayBuffer standing in for an offline dataset (no Minari/D4RL needed)."""
20+
rb = ReplayBuffer(storage=LazyTensorStorage(n))
21+
rb.extend(
22+
TensorDict(
23+
{
24+
"observation": torch.randn(n, obs_dim),
25+
"action": torch.randn(n, action_dim),
26+
("next", "reward"): torch.randn(n, 1),
27+
},
28+
batch_size=[n],
29+
)
30+
)
31+
return rb
32+
33+
34+
def _make_online_data(n: int = 50, obs_dim: int = 4, action_dim: int = 2):
35+
return TensorDict(
36+
{
37+
"observation": torch.randn(n, obs_dim),
38+
"action": torch.randn(n, action_dim),
39+
("next", "reward"): torch.randn(n, 1),
40+
},
41+
batch_size=[n],
42+
)
43+
44+
45+
class TestOfflineToOnlineReplayBuffer:
46+
def test_construction_with_capacity(self):
47+
offline = _make_offline_buffer()
48+
rb = OfflineToOnlineReplayBuffer(
49+
offline_dataset=offline,
50+
online_capacity=500,
51+
offline_fraction=0.5,
52+
batch_size=32,
53+
)
54+
assert rb.offline_buffer is offline
55+
assert isinstance(rb.online_buffer, ReplayBuffer)
56+
assert len(rb.online_buffer) == 0
57+
58+
def test_construction_with_storage(self):
59+
offline = _make_offline_buffer()
60+
rb = OfflineToOnlineReplayBuffer(
61+
offline_dataset=offline,
62+
online_storage=LazyTensorStorage(500),
63+
batch_size=32,
64+
)
65+
assert isinstance(rb.online_buffer, ReplayBuffer)
66+
67+
def test_construction_requires_exactly_one_online_arg(self):
68+
offline = _make_offline_buffer()
69+
with pytest.raises(ValueError, match="not both"):
70+
OfflineToOnlineReplayBuffer(
71+
offline_dataset=offline,
72+
online_capacity=500,
73+
online_storage=LazyTensorStorage(500),
74+
)
75+
with pytest.raises(ValueError, match="one of"):
76+
OfflineToOnlineReplayBuffer(offline_dataset=offline)
77+
78+
@pytest.mark.parametrize("fraction", [-0.1, 0.0, 1.0, 1.5])
79+
def test_invalid_offline_fraction(self, fraction):
80+
offline = _make_offline_buffer()
81+
with pytest.raises(ValueError, match="offline_fraction"):
82+
OfflineToOnlineReplayBuffer(
83+
offline_dataset=offline,
84+
online_capacity=500,
85+
offline_fraction=fraction,
86+
)
87+
88+
def test_dataset_kwargs_rejected_for_object(self):
89+
offline = _make_offline_buffer()
90+
with pytest.raises(ValueError, match="only forwarded when"):
91+
OfflineToOnlineReplayBuffer(
92+
offline_dataset=offline,
93+
online_capacity=500,
94+
split_trajs=True, # stray dataset kwarg
95+
)
96+
97+
def test_extend_routes_to_online_only(self):
98+
offline = _make_offline_buffer(n=1000)
99+
rb = OfflineToOnlineReplayBuffer(
100+
offline_dataset=offline,
101+
online_capacity=500,
102+
batch_size=32,
103+
)
104+
offline_len_before = len(rb.offline_buffer)
105+
rb.extend(_make_online_data(50))
106+
assert len(rb.online_buffer) == 50
107+
# offline is untouched
108+
assert len(rb.offline_buffer) == offline_len_before
109+
110+
def test_sample_falls_back_to_offline_when_online_empty(self):
111+
offline = _make_offline_buffer()
112+
rb = OfflineToOnlineReplayBuffer(
113+
offline_dataset=offline,
114+
online_capacity=500,
115+
batch_size=32,
116+
)
117+
batch = rb.sample(32)
118+
assert batch.batch_size == torch.Size([32])
119+
120+
def test_sample_returns_flat_batch(self):
121+
offline = _make_offline_buffer()
122+
rb = OfflineToOnlineReplayBuffer(
123+
offline_dataset=offline,
124+
online_capacity=500,
125+
batch_size=32,
126+
)
127+
rb.extend(_make_online_data(50))
128+
batch = rb.sample(64)
129+
# Flat [64], NOT [2, 32]
130+
assert batch.batch_size == torch.Size([64])
131+
132+
def test_sample_uses_default_batch_size(self):
133+
offline = _make_offline_buffer()
134+
rb = OfflineToOnlineReplayBuffer(
135+
offline_dataset=offline,
136+
online_capacity=500,
137+
batch_size=16,
138+
)
139+
rb.extend(_make_online_data(50))
140+
batch = rb.sample()
141+
assert batch.batch_size == torch.Size([16])
142+
143+
def test_sample_without_batch_size_raises(self):
144+
offline = _make_offline_buffer()
145+
rb = OfflineToOnlineReplayBuffer(
146+
offline_dataset=offline,
147+
online_capacity=500,
148+
)
149+
rb.extend(_make_online_data(50))
150+
with pytest.raises(ValueError, match="batch_size must be provided"):
151+
rb.sample()
152+
153+
@pytest.mark.parametrize("fraction", [0.25, 0.5, 0.75])
154+
def test_offline_fraction_respected_exactly(self, fraction):
155+
# Tag offline source=0, online source=1 so we can count exactly.
156+
offline = ReplayBuffer(storage=LazyTensorStorage(2000))
157+
offline.extend(
158+
TensorDict(
159+
{
160+
"observation": torch.randn(2000, 4),
161+
"source": torch.zeros(2000, dtype=torch.long),
162+
},
163+
[2000],
164+
)
165+
)
166+
rb = OfflineToOnlineReplayBuffer(
167+
offline_dataset=offline,
168+
online_capacity=2000,
169+
offline_fraction=fraction,
170+
batch_size=32,
171+
)
172+
rb.extend(
173+
TensorDict(
174+
{
175+
"observation": torch.randn(500, 4),
176+
"source": torch.ones(500, dtype=torch.long),
177+
},
178+
[500],
179+
)
180+
)
181+
batch = rb.sample(100)
182+
offline_count = (batch["source"] == 0).sum().item()
183+
# Deterministic: exactly round(fraction * batch_size) offline samples.
184+
assert offline_count == round(fraction * 100)
185+
186+
def test_anneal_reduces_offline_fraction(self):
187+
offline = _make_offline_buffer()
188+
rb = OfflineToOnlineReplayBuffer(
189+
offline_dataset=offline,
190+
online_capacity=500,
191+
offline_fraction=0.8,
192+
batch_size=32,
193+
)
194+
# halfway: 0.8 * (1 - 0.5) = 0.4
195+
rb.anneal(step=50, total_steps=100)
196+
assert abs(rb.offline_fraction - 0.4) < 1e-6
197+
# fully annealed: offline fraction -> 0
198+
rb.anneal(step=100, total_steps=100)
199+
assert rb.offline_fraction == 0.0
200+
201+
def test_anneal_clamps_past_total_steps(self):
202+
offline = _make_offline_buffer()
203+
rb = OfflineToOnlineReplayBuffer(
204+
offline_dataset=offline,
205+
online_capacity=500,
206+
offline_fraction=0.5,
207+
batch_size=32,
208+
)
209+
rb.anneal(step=200, total_steps=100)
210+
assert rb.offline_fraction == 0.0 # does not go negative
211+
212+
def test_fully_annealed_samples_online_only(self):
213+
offline = ReplayBuffer(storage=LazyTensorStorage(1000))
214+
offline.extend(
215+
TensorDict(
216+
{
217+
"observation": torch.randn(1000, 4),
218+
"source": torch.zeros(1000, dtype=torch.long),
219+
},
220+
[1000],
221+
)
222+
)
223+
rb = OfflineToOnlineReplayBuffer(
224+
offline_dataset=offline,
225+
online_capacity=1000,
226+
offline_fraction=0.5,
227+
batch_size=32,
228+
)
229+
rb.extend(
230+
TensorDict(
231+
{
232+
"observation": torch.randn(500, 4),
233+
"source": torch.ones(500, dtype=torch.long),
234+
},
235+
[500],
236+
)
237+
)
238+
rb.anneal(step=100, total_steps=100)
239+
batch = rb.sample(64)
240+
assert (batch["source"] == 1).all() # all online
241+
242+
def test_len(self):
243+
offline = _make_offline_buffer(n=1000)
244+
rb = OfflineToOnlineReplayBuffer(
245+
offline_dataset=offline,
246+
online_capacity=500,
247+
batch_size=32,
248+
)
249+
rb.extend(_make_online_data(50))
250+
assert len(rb) == 1050
251+
252+
253+
class TestPrefillReplayBuffer:
254+
def test_prefill_exact_n_samples(self):
255+
offline = _make_offline_buffer(n=1000)
256+
target = ReplayBuffer(storage=LazyTensorStorage(10_000))
257+
prefill_replay_buffer(target, offline, n_samples=200)
258+
assert len(target) == 200
259+
260+
def test_prefill_full_dataset(self):
261+
offline = _make_offline_buffer(n=300)
262+
target = ReplayBuffer(storage=LazyTensorStorage(10_000))
263+
prefill_replay_buffer(target, offline)
264+
assert len(target) == 300
265+
266+
def test_prefill_caps_at_dataset_size(self):
267+
offline = _make_offline_buffer(n=100)
268+
target = ReplayBuffer(storage=LazyTensorStorage(10_000))
269+
prefill_replay_buffer(target, offline, n_samples=500)
270+
# cannot copy more than the dataset holds
271+
assert len(target) == 100
272+
273+
def test_prefill_returns_buffer_for_chaining(self):
274+
offline = _make_offline_buffer(n=300)
275+
target = ReplayBuffer(storage=LazyTensorStorage(10_000))
276+
result = prefill_replay_buffer(target, offline, n_samples=50)
277+
assert result is target
278+
279+
def test_prefill_respects_chunk_size(self):
280+
offline = _make_offline_buffer(n=1000)
281+
target = ReplayBuffer(storage=LazyTensorStorage(10_000))
282+
prefill_replay_buffer(target, offline, n_samples=250, chunk_size=37)
283+
assert len(target) == 250
284+
285+
286+
class TestLoadDataset:
287+
def test_missing_prefix_raises(self):
288+
with pytest.raises(ValueError, match="must be prefixed"):
289+
load_dataset("halfcheetah-medium-v2")
290+
291+
def test_unknown_prefix_raises(self):
292+
with pytest.raises(ValueError, match="Unknown dataset source"):
293+
load_dataset("mujoco:hopper-v0")
294+
295+
def test_minari_prefix_routes_to_minari(self, monkeypatch):
296+
captured = {}
297+
298+
class FakeMinari:
299+
def __init__(self, dataset_id, **kwargs):
300+
captured["dataset_id"] = dataset_id
301+
captured["kwargs"] = kwargs
302+
303+
import torchrl.data.datasets.minari_data as minari_mod
304+
305+
monkeypatch.setattr(minari_mod, "MinariExperienceReplay", FakeMinari)
306+
load_dataset("minari:mujoco/hopper/expert-v0", batch_size=256)
307+
assert captured["dataset_id"] == "mujoco/hopper/expert-v0"
308+
assert captured["kwargs"] == {"batch_size": 256}
309+
310+
def test_d4rl_prefix_routes_to_d4rl(self, monkeypatch):
311+
captured = {}
312+
313+
class FakeD4RL:
314+
def __init__(self, dataset_id, **kwargs):
315+
captured["dataset_id"] = dataset_id
316+
captured["kwargs"] = kwargs
317+
318+
import torchrl.data.datasets.d4rl as d4rl_mod
319+
320+
monkeypatch.setattr(d4rl_mod, "D4RLExperienceReplay", FakeD4RL)
321+
load_dataset("d4rl:halfcheetah-medium-v2", split_trajs=True)
322+
assert captured["dataset_id"] == "halfcheetah-medium-v2"
323+
assert captured["kwargs"] == {"split_trajs": True}
324+
325+
def test_string_construction_through_buffer(self, monkeypatch):
326+
"""OfflineToOnlineReplayBuffer resolves string datasets via load_dataset."""
327+
offline = _make_offline_buffer()
328+
329+
import torchrl.data.datasets.d4rl as d4rl_mod
330+
331+
monkeypatch.setattr(
332+
d4rl_mod, "D4RLExperienceReplay", lambda dataset_id, **kw: offline
333+
)
334+
rb = OfflineToOnlineReplayBuffer(
335+
"d4rl:halfcheetah-medium-v2",
336+
online_capacity=500,
337+
batch_size=32,
338+
)
339+
assert rb.offline_buffer is offline
340+
341+
342+
if __name__ == "__main__":
343+
args, unknown = argparse.ArgumentParser().parse_known_args()
344+
pytest.main([__file__, "--capture", "no", "--exitfirst"] + unknown)

torchrl/data/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
ListStorageCheckpointer,
5050
Nested2TED,
5151
NestedStorageCheckpointer,
52+
OfflineToOnlineReplayBuffer,
53+
prefill_replay_buffer,
5254
PrioritizedReplayBuffer,
5355
PrioritizedSampler,
5456
PrioritizedSliceSampler,
@@ -146,6 +148,7 @@
146148
"Nested2TED",
147149
"NestedStorageCheckpointer",
148150
"NonTensor",
151+
"OfflineToOnlineReplayBuffer",
149152
"OneHot",
150153
"PairwiseDataset",
151154
"PrioritizedReplayBuffer",
@@ -206,6 +209,7 @@
206209
"contains_lazy_spec",
207210
"create_infinite_iterator",
208211
"get_dataloader",
212+
"prefill_replay_buffer",
209213
"set_video_decoder_cache_size",
210214
"validate_vla_tensordict",
211215
]

torchrl/data/datasets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .atari_dqn import AtariDQNExperienceReplay
1010
from .common import BaseDatasetExperienceReplay
1111
from .lerobot import lerobot_columns_to_tensordict, LeRobotExperienceReplay
12+
from .utils import load_dataset
1213

1314
# Conditional imports for classes with external dependencies
1415
try:
@@ -49,6 +50,7 @@
4950
__all__ = [
5051
"AtariDQNExperienceReplay",
5152
"BaseDatasetExperienceReplay",
53+
"load_dataset",
5254
"D4RLExperienceReplay",
5355
"MinariExperienceReplay",
5456
"GenDGRLExperienceReplay",

0 commit comments

Comments
 (0)