Study Archives/Robotics

[자율 조작 로봇 이해 ⑦] 학습이 끝난 로봇을 평가하는 법 — 추론, 일반화 검증, 정량적 평가

ns4A

들어가며

딥러닝 모델을 학습시킬 때 우리가 당연하게 하는 일들이 있습니다. model.train()으로 학습하고, 끝나면 model.eval()로 전환해서 테스트셋에 돌려봅니다. 학습 손실(training loss)이 잘 떨어졌어도, 본적 없는 데이터에서 성능이 나와야 비로소 "잘 학습됐다"고 말할 수 있죠. 훈련셋 정확도 99%짜리 모델이 테스트셋에서 60%가 나온다면, 우리는 주저 없이 "과적합(Overfitting)됐다"고 합니다.

로봇 정책 평가도 정확히 같은 구조입니다. 이전 글(LeRobot으로 모방학습 시작하기)에서 ACT 정책을 학습시키고, loss가 6.4에서 1.8까지 떨어지는 것을 확인했습니다. 그런데 그게 끝일까요?

"loss가 낮다 = 로봇이 잘 한다"는 보장이 없습니다. 학습 데이터에서의 행동은 잘 흉내내지만, 물체 위치가 조금만 달라지거나, 카메라 각도가 살짝 바뀌면 전혀 다른 결과가 나올 수 있습니다. 딥러닝에서 말하는 일반화(Generalization) 문제가 로봇 정책에도 그대로 적용됩니다.

이번 글에서는 학습이 끝난 정책을 제대로 평가하는 방법을 다룹니다. 핵심 질문은 두 가지입니다.

"학습된 정책이 시뮬레이터 안에서 실제로 잘 작동하는가?" 그리고 "학습할 때 보지 못한 새로운 상황에서도 통하는가?"


1. 이전 글 이어받기 — 체크포인트 로드와 추론

이전 글에서 아래 명령어로 학습을 완료했습니다.

 
 
python src/lerobot/scripts/lerobot_train.py \
  --dataset.repo_id=lerobot/pusht \
  --policy.type=act \
  --output_dir=outputs/train/act_pusht \
  --steps=1000

학습이 끝나면 체크포인트가 이 경로에 저장됩니다.

 
 
outputs/train/act_pusht/
└── checkpoints/
    └── last/
        └── pretrained_model/
            ├── config.json
            └── model.safetensors

이제 이 체크포인트를 불러와서 추론(inference)하는 코드를 작성합니다. 딥러닝에서 torch.load()로 가중치 불러오는 것과 같은 개념이에요.

 
 
import torch
from lerobot.common.policies.act.modeling_act import ACTPolicy

# 1. 학습된 정책 로드
CHECKPOINT_PATH = "outputs/train/act_pusht/checkpoints/last/pretrained_model"
policy = ACTPolicy.from_pretrained(CHECKPOINT_PATH)

# 2. 추론 모드로 전환 (BatchNorm, Dropout 등 비활성화)
#    딥러닝의 model.eval()과 완전히 동일한 역할
policy.eval()
policy.to("cuda" if torch.cuda.is_available() else "cpu")

print("정책 로드 완료!")
print(f"디바이스: {next(policy.parameters()).device}")

추론할 때는 policy.select_action(observation)을 호출합니다. observation은 카메라 이미지와 로봇 상태값을 담은 딕셔너리입니다.

 
 
 
# observation 구조 예시
# 실제로는 시뮬레이터나 카메라에서 받아옴
observation = {
    "observation.image": image_tensor,   # (C, H, W) 형태 이미지
    "observation.state": state_tensor,   # 로봇 관절/위치 상태
}

# 추론 — 다음에 수행할 액션 청크 반환
with torch.no_grad():
    action = policy.select_action(observation)
    # action shape: (chunk_size, action_dim)
    # pusht 기준: (100, 2) — 100 스텝의 x, y 이동값

딥러닝으로 치면 with torch.no_grad(): output = model(input) 과 구조가 완전히 같습니다. 학습 때와 달리 그래디언트를 계산할 필요가 없으니까요.


2. 일반화 검증 — 낯선 환경에서도 통할까?

학습 데이터에서 pusht의 초기 위치는 매번 조금씩 달랐지만, 대체로 비슷한 범위 안에 있었습니다. 만약 물체를 학습 때 보지 못한 위치에 놓으면 어떻게 될까요?

이걸 검증하는 방법이 일반화 테스트(Generalization Test)입니다. 딥러닝에서 "테스트셋을 학습셋과 다른 분포에서 뽑아본다"는 개념과 같아요.

pusht 환경에서는 에피소드 시작 시 물체와 로봇의 초기 위치를 랜덤하게 바꿀 수 있습니다.

 
import gym_pusht  # pusht 시뮬레이션 환경
import gymnasium as gym
import numpy as np

# 환경 생성
env = gym.make("gym_pusht/PushT-v0", render_mode="rgb_array")

# 랜덤 시드를 바꿔가며 다양한 초기 조건 테스트
seeds = [42, 123, 456, 789, 1024]
results = []

for seed in seeds:
    obs, info = env.reset(seed=seed)  # seed마다 초기 위치가 달라짐
    
    done = False
    total_reward = 0
    
    while not done:
        # observation을 정책에 맞는 형태로 변환
        obs_tensor = prepare_observation(obs)
        
        with torch.no_grad():
            action = policy.select_action(obs_tensor)
        
        # 액션 실행
        obs, reward, terminated, truncated, info = env.step(
            action.cpu().numpy()
        )
        total_reward += reward
        done = terminated or truncated
    
    success = info.get("is_success", False)
    results.append({"seed": seed, "reward": total_reward, "success": success})
    print(f"seed={seed}: reward={total_reward:.2f}, success={success}")

중요한 포인트가 있습니다. 학습 때 사용한 시드 범위 안의 테스트는 "in-distribution" 평가, 그 바깥의 시드는 "out-of-distribution" 평가입니다. 딥러닝에서 말하는 도메인 일반화(Domain Generalization)와 같은 맥락이에요.

일반적으로 ACT처럼 오차 누적이 적은 모델도 충분히 다른 초기 조건에서는 성능이 떨어집니다. 이걸 수치로 확인하는 게 다음 단계입니다.


3. 정량적 평가 — 숫자로 말해야 한다

"로봇이 잘 움직이더라"는 정성적 평가만으로는 부족합니다. 딥러닝에서 accuracy, F1-score, mAP 같은 숫자 지표를 쓰듯, 로봇 정책도 숫자로 표현해야 비교할 수 있습니다.

가장 기본적인 지표는 평균 액션 오차(Mean Action Error, MAE)입니다. 정책이 예측한 행동과 전문가가 실제로 한 행동의 차이를 평균 낸 값이에요.

 
from torch.utils.data import DataLoader
from lerobot.common.datasets.lerobot_dataset import LeRobotDataset
import numpy as np

# 평가용 데이터셋 로드 (학습에 쓰지 않은 에피소드들)
eval_dataset = LeRobotDataset(
    "lerobot/pusht",
    episodes=[180, 181, 182, 183, 184, 185]  # 학습에 사용하지 않은 에피소드
)
eval_loader = DataLoader(eval_dataset, batch_size=32, shuffle=False)

# Mean Action Error 계산
all_errors = []

policy.eval()
device = next(policy.parameters()).device

for batch in eval_loader:
    # 배치를 GPU로 이동
    obs = {
        "observation.image": batch["observation.image"].to(device),
        "observation.state": batch["observation.state"].to(device),
    }
    gt_action = batch["action"].to(device)  # Ground Truth: 전문가의 실제 행동
    
    with torch.no_grad():
        pred_action = policy.select_action(obs)  # 정책의 예측 행동
    
    # L1 오차 계산 (딥러닝의 MAE 손실과 동일)
    error = torch.mean(torch.abs(pred_action - gt_action[:, :pred_action.shape[1], :])).item()
    all_errors.append(error)

mae = np.mean(all_errors)
print(f"Mean Action Error: {mae:.4f}")

성공률(Success Rate)도 중요한 지표입니다.

 
# 여러 번 에피소드 실행 후 성공률 집계
n_eval_episodes = 20
successes = 0

for i in range(n_eval_episodes):
    obs, _ = env.reset(seed=i + 1000)  # 학습 때 안 쓴 시드 범위
    done = False
    
    while not done:
        obs_tensor = prepare_observation(obs)
        with torch.no_grad():
            action = policy.select_action(obs_tensor)
        obs, _, terminated, truncated, info = env.step(action.cpu().numpy())
        done = terminated or truncated
    
    if info.get("is_success", False):
        successes += 1

success_rate = successes / n_eval_episodes * 100
print(f"성공률: {success_rate:.1f}% ({successes}/{n_eval_episodes})")

평가 시 주의사항이 있습니다. 매 타임스텝을 순차적으로 실행해야 하므로 배치 추론보다 시간이 오래 걸립니다. 또한 평가용 데이터셋이 없다면 직접 만들어야 하는데, 학습셋과 시드 범위를 완전히 분리하는 것이 중요합니다. 딥러닝의 train/validation 분리와 동일한 원칙이에요.


4. Temporal Ensemble — 청크 끊김 문제 해결

ACT의 액션 청킹 방식에는 한 가지 실용적인 문제가 있습니다. 청크 하나가 끝나고 다음 청크가 시작될 때 끊기는 현상(jerkiness)이 발생한다는 겁니다.

직관적으로 생각해보면, 청크 크기가 K=100이라면 100 스텝에 한 번씩 "새로운 계획"을 세웁니다. 그런데 99번째 스텝과 101번째 스텝 사이에서 정책의 "생각"이 바뀌면 동작이 갑자기 툭 끊기게 됩니다. 음악으로 비유하면, 100마디짜리 악보를 연주하다가 새 악보로 바꿀 때 잠깐 멈추는 것과 같아요.

Temporal Ensemble(시간적 앙상블)이 이 문제를 해결합니다.

아이디어는 간단합니다. 매 타임스텝마다 새로운 액션 청크를 예측하고, 여러 청크의 예측값을 지수 가중 평균(Exponentially Weighted Average)으로 합칩니다.

 
 
t=0에서 예측한 청크: [a₀, a₁, a₂, ..., a₉₉]
t=1에서 예측한 청크: [a₁', a₂', ..., a₁₀₀']
t=2에서 예측한 청크: [a₂'', a₃'', ..., a₁₀₁'']

t=2에서 실제 실행할 행동:
  = exp(-0 × λ) × a₂     (t=0 청크의 2번째)
  + exp(-1 × λ) × a₂'    (t=1 청크의 1번째)
  + exp(-2 × λ) × a₂''   (t=2 청크의 0번째)
  → 가중 평균!

λ가 Temporal Ensemble Coefficient입니다. 값이 작을수록(예: 0.01) 과거 청크도 많이 반영되어 부드럽고, 클수록 최신 청크 위주로 빠르게 반응합니다.

LeRobot에서는 설정 하나로 활성화할 수 있습니다.

 
 
# ACT 설정 시 Temporal Ensemble 활성화
from lerobot.common.policies.act.configuration_act import ACTConfig

config = ACTConfig(
    chunk_size=100,
    n_action_steps=100,
    temporal_ensemble_coeff=0.01,  # 0 이상 값으로 설정 시 활성화
)

딥러닝으로 비유하면, 이건 단일 모델 예측 대신 앙상블(Ensemble)을 쓰는 것과 비슷합니다. 단, 여러 모델이 아니라 여러 시점의 예측을 앙상블한다는 점이 다르죠. 결과적으로 동작이 훨씬 부드러워지고, 청크 경계에서의 불연속성이 크게 줄어듭니다.

 
 

 


5. 관절공간 vs 작업공간 — 어떤 표현이 더 좋을까?

4편에서 데이터를 수집할 때 두 가지 방식이 있다고 했습니다. 관절 각도를 직접 기록하는 관절공간(Joint Space) 방식과, 말단 장치의 위치와 방향을 기록하는 작업공간(Task Space) 방식이에요.

이 차이가 ACT 정책 학습에 어떤 영향을 미칠까요?

관절공간 방식

# State와 Action이 모두 관절 각도 벡터
# OMY 기준: 6개 관절 + 그리퍼
state  = [θ₁, θ₂, θ₃, θ₄, θ₅, θ₆, gripper]  # 7차원
action = [Δθ₁, Δθ₂, Δθ₃, Δθ₄, Δθ₅, Δθ₆, Δgripper]  # 7차원

 

작업공간 방식

# State와 Action이 말단 장치의 위치/방향
state  = [x, y, z, roll, pitch, yaw, gripper]  # 7차원
action = [Δx, Δy, Δz, Δroll, Δpitch, Δyaw, Δgripper]  # 7차원

차원 수는 같지만 의미가 다릅니다. 어떤 방식이 더 좋을까요?

일반적으로 작업공간 방식이 학습에 유리합니다. 사람이 텔레오퍼레이션으로 시연할 때도 "x 방향으로 이만큼 이동"하는 방식으로 조작하기 때문에, 작업공간 데이터가 전문가 의도를 더 직접적으로 담습니다. 모델 입장에서도 카메라 이미지(픽셀 공간)와 작업공간(3D 공간)의 대응 관계가 관절 각도보다 더 직관적입니다.

반면 관절공간 방식은 제어가 더 직접적입니다. 역기구학(IK) 계산 없이 모터에 바로 명령을 내릴 수 있어요.

실제 실험에서는 두 방식을 모두 학습시킨 뒤 성능을 비교하는 것이 권장됩니다. 태스크에 따라 어느 쪽이 유리한지 달라질 수 있기 때문입니다.

 
 
 
# 데이터셋 구성 시 작업공간 기반 설정
# (teleop.py에서 수집 시 task space로 기록한 경우)
config_taskspace = ACTConfig(
    chunk_size=50,
    input_features={
        "observation.image": ...,
        "observation.state": ...,  # task space state: [x, y, z, rx, ry, rz, gripper]
    },
    output_features={
        "action": ...,  # task space action: [dx, dy, dz, drx, dry, drz, dgripper]
    }
)

# 관절공간 기반 설정
config_jointspace = ACTConfig(
    chunk_size=50,
    input_features={
        "observation.image": ...,
        "observation.state": ...,  # joint space state: [θ₁, ..., θ₆, gripper]
    },
    output_features={
        "action": ...,  # joint space action: [Δθ₁, ..., Δθ₆, Δgripper]
    }
)

시각 정보(카메라 이미지)가 있는 환경에서는 작업공간이 유리한 경우가 많습니다. 반면 이미지 없이 고유수용성 감각(관절 센서) 데이터만 쓸 때는 관절공간이 더 직접적입니다.


마치며

이번 글에서 다룬 내용을 한 줄씩 정리하면 이렇습니다.

학습이 끝난 ACT 정책은 from_pretrained()로 로드하고 eval() 모드로 전환한 뒤, select_action()으로 추론합니다 — 딥러닝의 train/eval 전환과 완전히 같은 구조입니다.

일반화 검증은 학습 때 쓰지 않은 시드(초기 조건)로 에피소드를 실행하는 것으로, 딥러닝의 test-set 평가와 같은 원리입니다.

정량적 평가의 기본 지표는 Mean Action Error(MAE)성공률(Success Rate) 이며, 두 수치가 함께 높아야 "잘 학습됐다"고 말할 수 있습니다.

Temporal Ensemble은 여러 시점의 청크 예측을 지수 가중 평균으로 합쳐, 청크 경계의 끊김 현상을 부드럽게 만드는 기법입니다.

관절공간과 작업공간은 같은 7차원이지만 학습 특성이 다르며, 비전 기반 모방학습에서는 일반적으로 작업공간 표현이 유리합니다.