Source code for unilab.base.curriculum
"""Curriculum learning for adaptive difficulty adjustment."""
from __future__ import annotations
import torch
[docs]
class EpisodeLengthTracker:
"""Track a weighted moving average of episode lengths on one tensor."""
[docs]
def __init__(self, num_envs: int, window_size: int = 1000, *, device=None):
self.num_envs = num_envs
self.window_size = max(1, int(window_size * num_envs / 4096))
device = torch.device("cpu") if device is None else torch.device(device)
self._average = torch.zeros((), dtype=torch.float64, device=device)
[docs]
def update(self, episode_lengths: torch.Tensor) -> None:
"""Recursively update the average without synchronizing each batch."""
count = int(episode_lengths.numel())
if count == 0:
return
lengths = episode_lengths.to(dtype=torch.float64, device=self._average.device)
batch_average = lengths.sum() / count
weight = torch.as_tensor(
min(count / self.window_size, 1.0),
dtype=torch.float64,
device=self._average.device,
)
self._average.add_((batch_average - self._average) * weight)
[docs]
def update_masked(self, episode_lengths: torch.Tensor, mask: torch.Tensor) -> None:
"""Update from a fixed-width episode-length carrier and bool mask.
This boundary avoids materializing reset
rows; false values are excluded with fixed-shape ``where`` operations.
"""
if mask.shape != episode_lengths.shape:
raise ValueError("episode lengths and update mask must share one shape")
selected_mask = mask.to(device=self._average.device, dtype=torch.bool)
count = selected_mask.sum()
if bool(count == 0):
return
lengths = episode_lengths.to(dtype=torch.float64, device=self._average.device)
selected = torch.where(
selected_mask,
lengths,
torch.zeros_like(lengths),
)
batch_average = selected.sum() / count.clamp(min=1)
weight = (count.to(dtype=torch.float64) / float(self.window_size)).clamp(max=1.0)
self._average.add_((batch_average - self._average) * weight)
@property
def average(self) -> torch.Tensor:
"""Recursive average as a device-resident scalar tensor."""
return self._average