NumPy#

LEAPP traces NumPy code. Arrays handed to input_tensors() come back as TracedNpArray, and the NumPy operations applied to them are recorded into the same graph as the surrounding PyTorch code. A pipeline can keep the NumPy preprocessing it already has instead of rewriting it in torch before it can be exported.

The exported artifact is always a torch graph. NumPy is a frontend to that graph, not a second export backend. See How it works for the interception model and Limitations for the cases that follow from that.

Example: a dataset-driven policy pipeline#

Datasets in the LeRobot format yield frames as NumPy arrays, so normalization runs in NumPy and only the policy itself needs torch. LEAPP traces straight through the handover.

import numpy as np
import torch
import leapp
from leapp import annotate

# Normalization statistics that ship with the dataset.
STATE_MEAN = np.zeros(6, dtype=np.float32)
STATE_STD = np.full(6, 0.5, dtype=np.float32)

class Policy(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.net = torch.nn.Linear(9, 6)

    def forward(self, obs: torch.Tensor) -> torch.Tensor:
        return torch.tanh(self.net(obs))

def preprocess(frame: dict) -> np.ndarray:
    """Runs on the NumPy arrays the dataset yields."""
    state, velocity = annotate.input_tensors("preprocess", {
        "state": frame["observation.state"],
        "velocity": frame["observation.velocity"],
    })

    state_norm = np.clip((state - STATE_MEAN) / STATE_STD, -5.0, 5.0)
    obs = np.concatenate([state_norm, velocity])

    annotate.output_tensors("preprocess", {"obs": obs}, export_with="jit")
    return obs

def run_policy(policy: Policy, obs: np.ndarray) -> np.ndarray:
    traced_obs = annotate.input_tensors("policy", {"obs": obs})

    action = policy(torch.from_numpy(traced_obs).unsqueeze(0))
    action = action.squeeze(0).numpy()

    annotate.output_tensors("policy", {"action": action}, export_with="onnx")
    return action

def main():
    policy = Policy().eval()
    frame = {
        "observation.state": np.zeros(6, dtype=np.float32),
        "observation.velocity": np.zeros(3, dtype=np.float32),
    }

    leapp.start(name="lerobot_pipeline")
    obs = preprocess(frame)
    run_policy(policy, obs)
    leapp.stop()
    leapp.compile_graph()

torch.from_numpy() and .numpy() are traced conversions, not trace breaks: they keep the recorded chain intact and, when shape and dtype are unchanged, they also keep the node boundary that connects preprocess to policy. The exported bundle wires the two nodes together:

pipeline:
  data_flow:
    preprocess/obs: [policy/obs]
  inputs:
    preprocess: [state, velocity]
  outputs:
    policy: [action]

Note

Conversion between NumPy and torch relies on the patches LEAPP installs for the duration of a tracing session. Starting with leapp.start(..., global_patching=False) disables them, and conversions then return untraced values.