Rate this Page

Source code for torchrl.record.loggers.wandb

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import annotations

import importlib.metadata as metadata
import importlib.util
import json

import os
import platform
import sys
from collections.abc import Mapping, Sequence
from typing import Any

from tensordict import TensorDictBase
from torch import Tensor

from .common import _make_metrics_safe, Logger

_has_wandb = importlib.util.find_spec("wandb") is not None
_has_omegaconf = importlib.util.find_spec("omegaconf") is not None
_has_moviepy = importlib.util.find_spec("moviepy") is not None


def _collect_env_metadata() -> dict[str, Any]:
    packages = {}
    editable_sources = {}
    for distribution in metadata.distributions():
        name = distribution.metadata.get("Name")
        if name is None:
            continue
        packages[name] = distribution.version
        direct_url = distribution.read_text("direct_url.json")
        if direct_url is not None:
            direct_url = json.loads(direct_url)
            dir_info = direct_url.get("dir_info")
            if dir_info is not None and dir_info.get("editable"):
                editable_sources[name] = direct_url.get("url")
    return {
        "python": {
            "version": sys.version,
            "executable": sys.executable,
            "prefix": sys.prefix,
            "base_prefix": sys.base_prefix,
            "platform": platform.platform(),
        },
        "packages": dict(sorted(packages.items())),
        "editable_sources": dict(sorted(editable_sources.items())),
    }


[docs] class WandbLogger(Logger): """Wrapper for the wandb logger. See also :class:`~torchrl.trainers.algorithms.configs.WandbLoggerConfig`. The keyword arguments are mainly based on the :func:`wandb.init` kwargs. See the doc `here <https://docs.wandb.ai/ref/python/init>`__. Args: exp_name (str): The name of the experiment. offline (bool, optional): if ``True``, the logs will be stored locally only. Defaults to ``False``. save_dir (path, optional): the directory where to save data. Exclusive with ``log_dir``. log_dir (path, optional): the directory where to save data. Exclusive with ``save_dir``. id (str, optional): A unique ID for this run, used for resuming. It must be unique in the project, and if you delete a run you can't reuse the ID. project (str, optional): The name of the project where you're sending the new run. If the project is not specified, the run is put in an ``"Uncategorized"`` project. base_url (str, optional): The W&B server URL used for authentication and data synchronization. Construct the logger before other code imports or calls W&B, because the W&B client reads ``WANDB_BASE_URL`` when it is initialized. log_env_packages (bool, optional): if ``True``, logs the Python runtime, installed package versions, and editable source locations under ``wandb.config["env"]``. Defaults to ``True``. Keyword Args: fps (int, optional): Number of frames per second when recording videos. Defaults to ``30``. **kwargs: Extra keyword arguments for ``wandb.init``. See relevant page for more info. """ @classmethod def __new__(cls, *args, **kwargs): return super().__new__(cls) def __init__( self, exp_name: str, offline: bool = False, save_dir: str | os.PathLike[str] | None = None, id: str | None = None, project: str | None = None, base_url: str | None = None, *, video_fps: int = 32, log_env_packages: bool = True, **kwargs, ) -> None: if not _has_wandb: raise ImportError("wandb could not be imported") log_dir: str | os.PathLike[str] | None = kwargs.pop("log_dir", None) self.offline = offline save_dir = save_dir or None log_dir = log_dir or None if save_dir is not None and log_dir is not None: raise ValueError( "log_dir and save_dir point to the same value in " "WandbLogger. Both cannot be specified." ) save_dir = save_dir if save_dir is not None else log_dir if save_dir is not None: save_dir = os.path.expanduser(os.fspath(save_dir)) os.makedirs(save_dir, exist_ok=True) self.save_dir = save_dir self.id = id self.project = project self.base_url = base_url self.video_fps = video_fps self.log_env_packages = log_env_packages self._step_registry: dict[str, int] = {} self._defined_step_metrics: set[str] = set() self._defined_metrics: set[str] = set() self._wandb_kwargs = { "name": exp_name, "dir": save_dir, "id": id, "project": project, "resume": "allow", **kwargs, } super().__init__(exp_name=exp_name, log_dir=save_dir) if self.log_env_packages: try: self.experiment.config.update( {"env": _collect_env_metadata()}, allow_val_change=True ) except TypeError: self.experiment.config.update({"env": _collect_env_metadata()}) if self.offline: os.environ["WANDB_MODE"] = "dryrun" def _checkpoint_state(self) -> dict[str, Any]: return { "id": getattr(self.experiment, "id", self.id), "step_registry": dict(self._step_registry), "defined_step_metrics": sorted(self._defined_step_metrics), "defined_metrics": sorted(self._defined_metrics), } def _load_checkpoint_state(self, state_dict: Mapping[str, Any]) -> None: saved_id = state_dict.get("id") live_id = getattr(self.experiment, "id", self.id) if saved_id and live_id and saved_id != live_id: raise RuntimeError( f"This WandbLogger logs to run {live_id!r} but the checkpoint was " f"written by run {saved_id!r}. Construct the logger for the saved " "run before loading, e.g. WandbLogger(..., id=saved_id, " "resume='must') or get_logger(..., state_dict=saved_state)." ) if saved_id: self.id = saved_id self._step_registry.clear() self._step_registry.update(state_dict.get("step_registry", {})) self._defined_step_metrics = set(state_dict.get("defined_step_metrics", ())) self._defined_metrics = set(state_dict.get("defined_metrics", ())) def _create_experiment(self): """Creates a wandb experiment. Args: exp_name (str): The name of the experiment. Returns: A wandb.Experiment object. """ if not _has_wandb: raise ImportError("Wandb is not installed") import wandb if self.offline: os.environ["WANDB_MODE"] = "dryrun" if self.base_url is not None: settings = self._wandb_kwargs.get("settings") if settings is None: settings = wandb.Settings(base_url=self.base_url) elif isinstance(settings, Mapping): settings = wandb.Settings(**{**settings, "base_url": self.base_url}) else: settings.base_url = self.base_url self._wandb_kwargs["settings"] = settings if not self.offline: wandb.login(host=self.base_url, key=getattr(settings, "api_key", None)) return wandb.init(**self._wandb_kwargs) def log_scalar( self, name: str, value: float, step: int | None = None, commit: bool = True, *, override_global_step: bool = False, ) -> None: """Logs a scalar value to wandb. Args: name (str): The name of the scalar. value (float): The value of the scalar. step (int, optional): The step at which the scalar is logged. Defaults to None. commit (bool, optional): If ``True``, data for the current step is assumed to be final (and no further data for this step should be logged). Set to ``False`` to batch multiple calls into the same W&B history row. Defaults to ``True``. override_global_step (bool, optional): If ``True``, bypasses per-group step injection and forwards ``step`` to wandb's global ``step`` argument. Defaults to ``False``. """ self._log_payload( {name: value}, step=step, commit=commit, override_global_step=override_global_step, ) def log_video(self, name: str, video: Tensor, **kwargs) -> None: """Log videos inputs to wandb. Args: name (str): The name of the video. video (Tensor): The video to be logged. **kwargs: Other keyword arguments. By construction, log_video supports 'step' (integer indicating the step index), 'format' (default is 'mp4') and 'fps' (defaults to ``self.video_fps``). Other kwargs are passed as-is to the :obj:`experiment.log` method. Raises: ImportError: If moviepy is not installed (required by wandb for video encoding). """ if not _has_moviepy: raise ImportError( "Video logging with wandb requires moviepy. " "Install with: pip install moviepy\n" "Or install wandb with media support: pip install 'wandb[media]'" ) import wandb fps = kwargs.pop("fps", self.video_fps) format = kwargs.pop("format", "mp4") self._log_payload( {name: wandb.Video(video, fps=fps, format=format)}, step=kwargs.pop("step", None), override_global_step=kwargs.pop("override_global_step", False), **kwargs, ) def log_hparams(self, cfg: DictConfig | dict) -> None: # noqa: F821 """Logs the hyperparameters of the experiment. Args: cfg (DictConfig or dict): The configuration of the experiment. """ if type(cfg) is not dict and _has_omegaconf: if not _has_omegaconf: raise ImportError( "OmegaConf could not be imported. " "Cannot log hydra configs without OmegaConf." ) from omegaconf import OmegaConf cfg = OmegaConf.to_container(cfg, resolve=True) self.experiment.config.update(cfg, allow_val_change=True) def __repr__(self) -> str: return f"WandbLogger(experiment={self.experiment.__repr__()})" def log_histogram(self, name: str, data: Sequence, **kwargs): """Add histogram to log. Args: name (str): Data identifier data (torch.Tensor, numpy.ndarray, or string/blobname): Values to build histogram Keyword Args: step (int): Global step value to record bins (str): One of {'tensorflow','auto', 'fd', …}. This determines how the bins are made. You can find other options in: https://docs.scipy.org/doc/numpy/reference/generated/numpy.histogram.html """ import wandb num_bins = kwargs.pop("bins", None) step = kwargs.pop("step", None) self._log_payload( {name: wandb.Histogram(data, num_bins=num_bins)}, step=step, override_global_step=kwargs.pop("override_global_step", False), **kwargs, ) def log_str( self, name: str, value: str, step: int | None = None, *, override_global_step: bool = False, ) -> None: """Logs a string value to wandb using a table format for better visualization. Args: name (str): The name of the string data. value (str): The string value to log. step (int, optional): The step at which the string is logged. Defaults to None. override_global_step (bool, optional): If ``True``, bypasses per-group step injection and forwards ``step`` to wandb's global ``step`` argument. Defaults to ``False``. """ import wandb # Create a table with a single row table = wandb.Table(columns=["text"], data=[[value]]) self._log_payload( {name: value if step is not None else table}, step=step, override_global_step=override_global_step, ) def log_metrics( self, metrics: dict[str, Any] | TensorDictBase, step: int | None = None, *, keys_sep: str = "/", override_global_step: bool = False, ) -> dict[str, Any]: """Log multiple scalar metrics at once to wandb. This method efficiently handles tensor values by batching CUDA->CPU transfers and performing a single synchronization, then logs all metrics in a single wandb API call. Args: metrics: Dictionary or TensorDict mapping metric names to values. Tensor values are automatically converted to Python scalars/lists. For TensorDict inputs, nested keys are flattened using ``keys_sep``. step: Optional step value for all metrics. keys_sep: Separator used to flatten nested TensorDict keys into strings. Defaults to "/". Only used for TensorDict inputs. override_global_step: If ``True``, bypasses per-group step injection and forwards ``step`` to wandb's global ``step`` argument. Defaults to ``False``. Returns: The converted metrics dictionary (with tensors converted to Python types). """ safe_metrics = _make_metrics_safe(metrics, keys_sep=keys_sep) self._log_payload( safe_metrics, step=step, override_global_step=override_global_step ) return safe_metrics @staticmethod def _is_step_key(name: str) -> bool: return name == "step" or name.endswith("/step") @staticmethod def _step_key(name: str) -> str: if WandbLogger._is_step_key(name): return name prefix, sep, _ = name.rpartition("/") return f"{prefix}{sep}step" if sep else "step" def _consume_step(self, step_key: str, step: int | None) -> int: last_step = self._step_registry.get(step_key, -1) if step is None: step = last_step + 1 self._step_registry[step_key] = max(last_step, step) return step def _define_metric(self, name: str, *, step_metric: str | None = None) -> None: if step_metric is None: if name in self._defined_step_metrics: return self._defined_step_metrics.add(name) self.experiment.define_metric(name) return if name in self._defined_metrics: return self._defined_metrics.add(name) self.experiment.define_metric(name, step_metric=step_metric) def _prepare_payload( self, payload: dict[str, Any], step: int | None ) -> dict[str, Any]: prepared = dict(payload) for key, value in list(prepared.items()): if self._is_step_key(key): self._consume_step(key, value) for key in list(prepared): if self._is_step_key(key): continue step_key = self._step_key(key) if step_key not in prepared: prepared[step_key] = self._consume_step(step_key, step) return prepared def _register_metrics(self, payload: dict[str, Any]) -> None: for key in payload: if self._is_step_key(key): self._define_metric(key) for key in payload: if self._is_step_key(key): continue self._define_metric(key, step_metric=self._step_key(key)) def _log_payload( self, payload: dict[str, Any], *, step: int | None = None, override_global_step: bool = False, **kwargs, ) -> None: if override_global_step: self.experiment.log(payload, step=step, **kwargs) return payload = self._prepare_payload(payload, step) self._register_metrics(payload) self.experiment.log(payload, **kwargs)