Skip to main content
Summary

시연 데이터가 교사가 되는 순간

모방학습의 첫 번째 구현은 생각보다 단순한 질문에서 시작한다.

"이 상태에서 사람은 어떤 행동을 했는가?"

20장에서 우리는 로봇의 상태를 state_t, 그때 사람이 내린 조작을 action_t로 표현했다. Behavior Cloning, 줄여서 BC는 바로 이 쌍을 이용한다. 입력은 로봇이 본 장면과 자신의 자세이고, 정답은 사람이 실제로 수행한 행동이다. 다시 말해 로봇 제어 문제를 지도학습 문제로 바꾸는 것이다.

이미지 분류 모델이 고양이 사진을 보고 cat이라는 정답을 맞히듯이, BC 모델은 로봇 상태를 보고 다음 행동을 맞힌다. 다만 여기서 정답은 단어가 아니라 로봇팔의 이동량, 그리퍼 열림 정도, 관절 속도 같은 숫자 벡터다.

예를 들어 컵을 집는 시연 데이터가 다음처럼 기록되어 있다고 하자.

state_t  = 카메라 이미지 + 현재 엔드 이펙터 위치 + 그리퍼 상태
action_t = 다음 순간에 사람이 조작한 이동 명령

모델은 많은 시연을 보며 이런 패턴을 배운다. 컵이 화면 왼쪽에 있고 그리퍼가 컵보다 오른쪽에 있으면 왼쪽으로 움직인다. 그리퍼가 컵 가까이에 도달하면 아래로 내려간다. 손가락이 컵 양쪽에 오면 그리퍼를 닫는다. 우리는 이 모든 규칙을 직접 if 문으로 쓰지 않는다. 대신 좋은 시연 데이터를 모아 모델이 그 규칙을 찾아내게 한다.

이 장의 목표는 하나다. 저장된 로봇 시연 로그를 학습 데이터셋으로 정리하고, 간단한 정책 모델을 학습시킨 뒤, 시뮬레이션에서 실행해 보는 것이다.

Behavior Cloning 구현

로그 파일을 학습 데이터셋으로 바꾸기

시연 로그는 보통 에피소드 단위로 저장된다. 하나의 에피소드는 사람이 로봇을 조작해 하나의 작업을 끝낸 기록이다. 예를 들어 "빨간 블록을 집어 상자에 넣기"를 한 번 수행했다면 그것이 하나의 에피소드다.

20장에서 본 samples.jsonl 형식을 다시 떠올려 보자. 한 줄에는 하나의 시점 데이터가 들어 있다.

{
  "episode_id": "ep_0007",
  "t": 42,
  "image_path": "images/ep_0007_0042.png",
  "joint_positions": [0.1, -0.4, 0.8, 1.2, -0.3, 0.5],
  "ee_position": [0.42, 0.05, 0.18],
  "gripper": 0.0,
  "action": [0.01, -0.02, 0.00, 0.0, 0.0, 0.0, 1.0]
}

여기서 action은 사람이 다음 순간에 내린 조작이다. 예를 들어 앞의 세 값은 엔드 이펙터의 x, y, z 방향 이동량이고, 마지막 값은 그리퍼를 닫는 명령일 수 있다. 중요한 점은 모든 샘플이 같은 입력 형식과 같은 출력 형식을 가져야 한다는 것이다.

학습 데이터셋을 만들 때는 다음 세 가지를 먼저 결정해야 한다.

  • 모델에 어떤 상태 정보를 넣을 것인가

  • 모델이 어떤 행동 표현을 출력할 것인가

  • 시간 순서 데이터를 독립 샘플로 볼 것인가, 짧은 시퀀스로 묶을 것인가

가장 단순한 BC는 한 시점의 상태만 보고 한 시점의 행동을 예측한다.

policy(state_t) → predicted_action_t

이 방식은 구현이 쉽고 디버깅하기 좋다. 하지만 로봇 행동에는 시간의 흐름이 중요하다. 컵을 향해 다가가는 중인지, 이미 컵을 잡았는지, 막 들어 올리는 중인지는 한 장의 이미지와 현재 좌표만으로 애매할 수 있다. 그래서 나중에는 여러 시점의 상태를 함께 넣는 시퀀스 모델로 확장할 수 있다. 그러나 첫 구현에서는 한 시점 입력, 한 시점 출력으로 시작하는 것이 좋다. 로봇 학습에서는 복잡한 모델보다 잘 정리된 데이터와 명확한 기준선이 훨씬 강하다.

학습용 샘플 정리하기

데이터셋 클래스를 만들 때 해야 할 일은 분명하다. 로그 파일을 읽고, 이미지와 상태 벡터를 불러오고, 행동 벡터를 정답으로 반환하면 된다.

아래 코드는 PyTorch 스타일의 간단한 데이터셋 예시다. 실제 프로젝트에서는 이미지 경로, 로봇 상태 키, 행동 차원 등을 자신의 로그 형식에 맞게 바꾸면 된다.

import json
from pathlib import Path

import torch
from torch.utils.data import Dataset
from PIL import Image
from torchvision import transforms


class RobotDemoDataset(Dataset):
    def __init__(self, log_path, image_root, use_image=True):
        self.log_path = Path(log_path)
        self.image_root = Path(image_root)
        self.use_image = use_image
        self.samples = []

        with open(self.log_path, 'r', encoding='utf-8') as f:
            for line in f:
                item = json.loads(line)
                self.samples.append(item)

        self.image_transform = transforms.Compose([
            transforms.Resize((128, 128)),
            transforms.ToTensor(),
        ])

    def __len__(self):
        return len(self.samples)

    def __getitem__(self, idx):
        item = self.samples[idx]

        joint = torch.tensor(item['joint_positions'], dtype=torch.float32)
        ee = torch.tensor(item['ee_position'], dtype=torch.float32)
        gripper = torch.tensor([item['gripper']], dtype=torch.float32)
        state_vec = torch.cat([joint, ee, gripper], dim=0)

        action = torch.tensor(item['action'], dtype=torch.float32)

        if not self.use_image:
            return {
                'state_vec': state_vec,
                'action': action,
            }

        image_path = self.image_root / item['image_path']
        image = Image.open(image_path).convert('RGB')
        image = self.image_transform(image)

        return {
            'image': image,
            'state_vec': state_vec,
            'action': action,
        }

이 코드에서 state_vec는 관절 위치, 엔드 이펙터 위치, 그리퍼 상태를 하나로 붙인 벡터다. 이것을 상태 벡터라고 부른다. 상태 벡터 기반 모델은 카메라를 사용하지 않고, 로봇의 숫자 센서 정보만 보고 행동을 예측한다.

반대로 이미지 기반 모델은 카메라 장면도 함께 사용한다. 컵이 어디에 있는지, 장애물이 있는지, 물체가 잡혔는지 같은 정보는 이미지에서 나온다. 실제 로봇 작업에서는 이미지와 상태 벡터를 함께 쓰는 경우가 많다. 카메라는 바깥세상을 알려 주고, 관절과 엔드 이펙터 상태는 로봇 자신의 몸 상태를 알려 주기 때문이다.

학습 데이터와 검증 데이터를 나누기

시연 데이터를 모두 학습에만 사용하면 모델이 정말 행동을 배운 것인지 확인하기 어렵다. 시험 문제와 정답지를 함께 외운 학생은 교실에서는 똑똑해 보일 수 있지만, 새 문제를 만나면 바로 흔들린다. 로봇 모델도 마찬가지다.

그래서 데이터를 학습용과 검증용으로 나눈다. 학습용 데이터는 모델의 가중치를 업데이트하는 데 쓰고, 검증용 데이터는 학습 중간중간 모델이 보지 못한 샘플에서도 잘 예측하는지 확인하는 데 쓴다.

주의할 점은 샘플 단위로 무작위 분할하면 안 되는 경우가 많다는 것이다. 같은 에피소드 안의 연속 프레임은 매우 비슷하다. t=42는 학습 데이터에 있고 t=43은 검증 데이터에 있다면, 모델은 거의 같은 장면을 이미 본 셈이다. 검증 성능이 실제보다 좋게 보일 수 있다.

따라서 가능하면 에피소드 단위로 나누는 편이 좋다.

train episodes: ep_0001 ~ ep_0080
valid episodes: ep_0081 ~ ep_0100

이렇게 나누면 검증 데이터는 모델이 보지 못한 작업 시도 전체가 된다. 실제 로봇을 새로 움직이는 상황과 더 비슷하다.

간단한 분할 기준은 다음과 같다.

  • 전체 에피소드의 70~90%를 학습용으로 사용한다.

  • 나머지 10~30%를 검증용으로 남긴다.

  • 실패한 시연과 성공한 시연을 섞어 넣을 때는 두 데이터셋의 비율이 크게 다르지 않게 한다.

첫 번째 실험에서는 성공 시연만 사용해도 된다. 다만 로봇이 나중에 실패를 복구하기를 원한다면 실패 직전의 상태와 회복 행동도 중요한 데이터가 된다. 이 이야기는 다음 장에서 더 깊게 다룬다.

상태 벡터 기반 정책 모델

가장 먼저 만들어 볼 모델은 상태 벡터 기반 정책이다. 입력은 숫자 벡터이고 출력도 숫자 벡터다. 구조가 단순해서 BC의 기본 흐름을 이해하기 좋다.

예를 들어 입력 벡터가 10차원이고 행동 벡터가 7차원이라고 하자. 모델은 다음과 같은 함수를 배운다.

R^10 → R^7

R^10은 실수 10개짜리 벡터 공간이라는 뜻이다. 어렵게 느껴지면 그냥 "숫자 10개를 받아 숫자 7개를 출력한다"라고 이해하면 된다.

간단한 다층 퍼셉트론, 즉 MLP 모델은 다음처럼 작성할 수 있다. MLP는 여러 개의 선형층과 비선형 활성화 함수를 쌓은 기본 신경망이다.

import torch
import torch.nn as nn


class StatePolicy(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, 128),
            nn.ReLU(),
            nn.Linear(128, 128),
            nn.ReLU(),
            nn.Linear(128, action_dim),
        )

    def forward(self, state_vec):
        return self.net(state_vec)

이 모델은 이미 물체 위치가 상태 벡터에 포함되어 있을 때 특히 잘 작동한다. 예를 들어 객체 검출과 좌표 변환 모듈이 object_position을 계산해 주고, 그 값을 상태 벡터에 넣는다면 카메라 이미지를 직접 보지 않아도 된다.

state_vec = joint_positions + ee_position + gripper + object_position

이 방식은 가볍고 빠르다. 로봇팔 목표 도달, 특정 위치로 이동, 이미 인식된 물체를 향해 접근하기 같은 문제에 적합하다. 대신 이미지 속 복잡한 상황을 직접 이해하지는 못한다. 물체가 가려졌는지, 테이블 위에 다른 장애물이 있는지, 그리퍼가 실제로 물체를 잡았는지는 별도의 인식 모듈이 알려 주어야 한다.

이미지 기반 정책 모델

이번에는 카메라 이미지를 함께 사용하는 모델을 생각해 보자. 입력은 이미지와 상태 벡터이고, 출력은 행동 벡터다.

image_t + state_vec_t → action_t

이미지는 픽셀 수가 많다. 128×128 RGB 이미지만 해도 숫자가 49,152개다. 이 숫자를 그대로 MLP에 넣으면 비효율적이다. 그래서 보통 CNN을 사용한다. CNN은 이미지의 지역적인 패턴을 잘 추출하는 신경망 구조다. 컵의 가장자리, 그리퍼 손가락, 테이블 경계 같은 시각적 특징을 압축된 벡터로 바꿔 준다.

아래는 간단한 이미지-상태 결합 정책 모델이다.

import torch
import torch.nn as nn


class ImageStatePolicy(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()

        self.cnn = nn.Sequential(
            nn.Conv2d(3, 16, kernel_size=5, stride=2),
            nn.ReLU(),
            nn.Conv2d(16, 32, kernel_size=5, stride=2),
            nn.ReLU(),
            nn.Conv2d(32, 64, kernel_size=5, stride=2),
            nn.ReLU(),
            nn.Flatten(),
        )

        with torch.no_grad():
            dummy = torch.zeros(1, 3, 128, 128)
            image_dim = self.cnn(dummy).shape[1]

        self.head = nn.Sequential(
            nn.Linear(image_dim + state_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, action_dim),
        )

    def forward(self, image, state_vec):
        image_feat = self.cnn(image)
        x = torch.cat([image_feat, state_vec], dim=1)
        return self.head(x)

여기서 중요한 부분은 torch.cat이다. CNN이 이미지에서 추출한 특징과 로봇 상태 벡터를 하나로 붙인다. 모델은 "지금 장면이 이렇게 보이고, 내 팔은 이 자세에 있으니, 다음에는 이렇게 움직이면 된다"라는 관계를 배운다.

처음부터 큰 모델을 쓰고 싶은 유혹이 생길 수 있다. 하지만 첫 실험에서는 작은 모델이 더 좋다. 작은 모델은 빨리 학습되고, 문제가 생겼을 때 원인을 찾기 쉽다. 만약 작은 모델도 훈련 데이터에서 행동을 맞히지 못한다면 데이터 형식, 행동 스케일, 정렬 문제를 먼저 의심해야 한다.

행동 벡터의 스케일 맞추기

BC에서 자주 생기는 문제 중 하나는 행동 값의 스케일이다. 어떤 행동 차원은 -0.02에서 0.02 사이의 작은 이동량이고, 어떤 차원은 0 또는 1로 표현되는 그리퍼 명령일 수 있다. 또 관절 속도 명령은 범위가 전혀 다를 수 있다.

신경망은 출력 차원의 스케일이 크게 다르면 학습하기 어려워한다. 큰 값의 오차가 손실 함수에서 더 강하게 반영되기 때문이다. 따라서 행동을 정규화하는 것이 좋다. 정규화는 값을 일정한 범위나 분포로 맞추는 작업이다.

가장 흔한 방법은 평균과 표준편차를 사용하는 것이다.

action_norm = (action - action_mean) / action_std

모델은 정규화된 행동을 예측하고, 실제 로봇에 명령을 보낼 때는 다시 원래 스케일로 되돌린다.

action = action_norm * action_std + action_mean

상태 벡터도 마찬가지다. 관절 각도, 위치 좌표, 그리퍼 값이 서로 다른 범위를 가진다면 상태도 정규화해 주는 편이 좋다. 이미지의 경우 ToTensor()를 사용하면 픽셀 값이 0~1 범위로 바뀐다. 필요하면 평균과 표준편차를 이용해 이미지도 정규화할 수 있다.

작은 숫자 하나가 로봇 행동에서는 큰 차이를 만들 수 있다. 모델 출력이 0.03만 어긋나도 그리퍼가 컵의 중심을 놓칠 수 있다. 그래서 데이터 스케일은 사소한 전처리가 아니라 로봇의 손끝 감각을 조정하는 일에 가깝다.

손실 함수: 사람 행동과 얼마나 비슷한가

지도학습에는 모델의 예측이 정답과 얼마나 다른지 측정하는 기준이 필요하다. 이것을 손실 함수라고 부른다. BC에서 가장 기본적인 손실 함수는 평균제곱오차, 즉 MSE다.

loss = mean((predicted_action - expert_action)^2)

모델이 예측한 행동과 사람이 실제로 한 행동의 차이를 제곱해서 평균낸다. 차이가 클수록 손실이 커지고, 모델은 손실을 줄이는 방향으로 업데이트된다.

연속적인 이동량이나 관절 속도를 예측할 때는 MSE가 좋은 출발점이다. 다만 그리퍼 열기/닫기처럼 이산적인 행동은 조금 다르게 다룰 수 있다. 예를 들어 그리퍼 명령이 open 또는 close 두 가지라면 분류 문제로 보고 교차 엔트로피 손실을 사용할 수 있다.

실무에서는 행동 벡터를 두 부분으로 나누는 경우가 많다.

move_action   = [dx, dy, dz, droll, dpitch, dyaw]
gripper_action = open 또는 close

이때 전체 손실은 이동 손실과 그리퍼 손실을 더해서 만든다.

total_loss = move_loss + 0.2 * gripper_loss

여기서 0.2는 가중치다. 그리퍼 손실이 너무 강하면 모델이 이동보다 그리퍼 분류에만 집중할 수 있고, 너무 약하면 잡아야 할 순간에 그리퍼를 닫지 못한다. 손실 가중치는 작업에 따라 조정해야 한다.

첫 구현에서는 모든 행동을 연속값으로 보고 MSE로 학습해도 충분하다. 이후 성능을 높이고 싶을 때 행동 타입별 손실을 분리하면 된다.

배치 학습으로 정책 훈련하기

데이터셋과 모델, 손실 함수가 준비되면 이제 학습 루프를 만들 수 있다. 배치 학습은 데이터를 여러 샘플씩 묶어서 모델에 넣는 방식이다. 한 번에 하나의 샘플만 보고 업데이트하면 학습이 불안정할 수 있다. 여러 샘플의 평균적인 방향으로 업데이트하면 더 안정적이다.

아래는 상태 벡터 기반 정책을 학습하는 간단한 예시다.

import torch
import torch.nn as nn
from torch.utils.data import DataLoader


def train_state_policy(train_dataset, valid_dataset, state_dim, action_dim):
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    model = StatePolicy(state_dim, action_dim).to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.MSELoss()

    train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
    valid_loader = DataLoader(valid_dataset, batch_size=64, shuffle=False)

    for epoch in range(50):
        model.train()
        train_loss_sum = 0.0

        for batch in train_loader:
            state_vec = batch['state_vec'].to(device)
            action = batch['action'].to(device)

            pred = model(state_vec)
            loss = loss_fn(pred, action)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            train_loss_sum += loss.item() * state_vec.size(0)

        train_loss = train_loss_sum / len(train_dataset)

        model.eval()
        valid_loss_sum = 0.0
        with torch.no_grad():
            for batch in valid_loader:
                state_vec = batch['state_vec'].to(device)
                action = batch['action'].to(device)
                pred = model(state_vec)
                loss = loss_fn(pred, action)
                valid_loss_sum += loss.item() * state_vec.size(0)

        valid_loss = valid_loss_sum / len(valid_dataset)
        print(f'epoch={epoch:03d} train_loss={train_loss:.6f} valid_loss={valid_loss:.6f}')

    return model

이 루프에서 model.train()은 학습 모드, model.eval()은 평가 모드다. 지금처럼 단순한 MLP에서는 큰 차이가 없어 보일 수 있지만, 드롭아웃이나 배치 정규화 같은 층을 쓰면 중요해진다. torch.no_grad()는 검증할 때 기울기를 계산하지 않겠다는 뜻이다. 검증에서는 모델을 업데이트하지 않기 때문에 메모리와 시간을 아낄 수 있다.

학습 로그를 볼 때는 학습 손실과 검증 손실의 흐름을 함께 봐야 한다. 학습 손실은 계속 내려가는데 검증 손실이 어느 순간부터 올라간다면 모델이 훈련 데이터에 과하게 맞춰지고 있을 가능성이 크다. 이것을 과적합이라고 한다.

이미지 정책 학습 루프

이미지 기반 정책도 구조는 거의 같다. 차이는 배치에서 image를 함께 꺼내 모델에 넣는다는 점뿐이다.

def train_image_policy(train_dataset, valid_dataset, state_dim, action_dim):
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    model = ImageStatePolicy(state_dim, action_dim).to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)
    loss_fn = nn.MSELoss()

    train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
    valid_loader = DataLoader(valid_dataset, batch_size=32, shuffle=False)

    for epoch in range(100):
        model.train()
        train_loss_sum = 0.0

        for batch in train_loader:
            image = batch['image'].to(device)
            state_vec = batch['state_vec'].to(device)
            action = batch['action'].to(device)

            pred = model(image, state_vec)
            loss = loss_fn(pred, action)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            train_loss_sum += loss.item() * image.size(0)

        train_loss = train_loss_sum / len(train_dataset)

        model.eval()
        valid_loss_sum = 0.0
        with torch.no_grad():
            for batch in valid_loader:
                image = batch['image'].to(device)
                state_vec = batch['state_vec'].to(device)
                action = batch['action'].to(device)

                pred = model(image, state_vec)
                loss = loss_fn(pred, action)
                valid_loss_sum += loss.item() * image.size(0)

        valid_loss = valid_loss_sum / len(valid_dataset)
        print(f'epoch={epoch:03d} train_loss={train_loss:.6f} valid_loss={valid_loss:.6f}')

    return model

이미지 모델은 상태 벡터 모델보다 느리다. 배치 크기도 작게 잡는 경우가 많다. GPU가 없다면 학습 시간이 꽤 길어질 수 있다. 처음에는 이미지 크기를 64×64나 128×128 정도로 줄이고, 작업도 단순하게 시작하자. 작은 블록을 특정 위치로 밀기, 이미 정해진 위치의 물체에 접근하기 같은 과제가 좋다.

검증 손실만 믿으면 안 되는 이유

검증 손실이 낮으면 기분이 좋아진다. 숫자가 내려가는 그래프는 언제나 개발자를 안심시킨다. 하지만 로봇에서는 검증 손실이 낮아도 실제 실행이 실패할 수 있다.

이유는 간단하다. 학습과 검증은 "기록된 상태에서 정답 행동을 얼마나 잘 맞혔는가"를 본다. 하지만 실제 실행에서는 모델의 행동이 다음 상태를 만든다. 한 번 오른쪽으로 2cm 잘못 움직이면, 다음 순간 로봇은 시연 데이터에 거의 없던 상태에 놓인다. 그 상태에서 모델은 더 이상 익숙한 정답을 찾지 못하고 또 다른 작은 실수를 한다. 작은 오차가 눈덩이처럼 커지는 것이다.

따라서 BC 모델은 반드시 닫힌 루프에서 테스트해야 한다. 닫힌 루프란 모델이 예측한 행동을 실제 환경 또는 시뮬레이션에 적용하고, 그 결과로 바뀐 상태를 다시 모델에 넣는 방식이다.

현재 상태 관측
→ 정책 모델이 행동 예측
→ 시뮬레이터 또는 로봇에 행동 적용
→ 다음 상태 관측
→ 다시 행동 예측

이 반복이 진짜 로봇 정책의 시험장이다. 오프라인 검증 손실은 예비 시험이고, 시뮬레이션 실행은 실전 모의고사에 가깝다.

시뮬레이션에서 정책 실행하기

학습된 정책을 바로 실제 로봇에 올리는 것은 위험하다. 예측 행동이 조금만 이상해도 로봇팔이 테이블에 부딪히거나 그리퍼가 물체를 밀어낼 수 있다. 그래서 먼저 시뮬레이션에서 실행한다.

시뮬레이션 테스트 루프는 학습 루프와 다르게 동작한다. 정답 행동은 사용하지 않는다. 모델의 예측 행동을 환경에 적용하고, 작업이 성공했는지 확인한다.

def run_policy_in_sim(env, model, max_steps=200):
    device = next(model.parameters()).device
    obs = env.reset()

    for step in range(max_steps):
        state_vec = torch.tensor(obs['state_vec'], dtype=torch.float32).unsqueeze(0).to(device)

        with torch.no_grad():
            action = model(state_vec).cpu().numpy()[0]

        obs, reward, done, info = env.step(action)

        if done:
            break

    return info

여기서 env는 시뮬레이션 환경이다. 강화학습에서 자주 보는 구조와 비슷하다. reset()은 환경을 초기화하고, step(action)은 행동을 적용한 뒤 다음 관측과 종료 여부를 반환한다. BC는 강화학습처럼 보상을 이용해 학습하지는 않지만, 테스트할 때는 같은 환경 인터페이스를 사용할 수 있다.

이미지 기반 정책이라면 관측에서 이미지도 함께 꺼내면 된다.

with torch.no_grad():
    action = model(image, state_vec).cpu().numpy()[0]

처음 테스트할 때는 행동 출력을 그대로 적용하지 말고 제한을 거는 것이 좋다. 예를 들어 한 스텝에서 엔드 이펙터가 움직일 수 있는 최대 거리를 제한한다.

action[:3] = clip(action[:3], -0.02, 0.02)

이런 제한은 모델의 자유도를 줄이지만 안전성을 높인다. 로봇은 빠르게 실패하는 것보다 천천히 안전하게 실패하는 편이 낫다. 천천히 실패하면 원인을 볼 수 있고, 로그를 남길 수 있고, 다음 실험을 설계할 수 있다.

성공률과 오차 평가하기

모델을 평가할 때는 손실 하나만 보지 말고 작업 기준의 지표를 함께 봐야 한다. 로봇은 숫자를 맞히려고 존재하는 것이 아니라 일을 끝내려고 존재하기 때문이다.

Pick and Place 작업이라면 다음과 같은 지표를 사용할 수 있다.

  • 성공률: 전체 시도 중 물체를 목표 위치에 놓은 비율

  • 평균 위치 오차: 최종 물체 위치와 목표 위치 사이의 거리

  • 평균 실행 시간: 작업을 완료하는 데 걸린 스텝 수 또는 초 단위 시간

  • 충돌 횟수: 테이블, 장애물, 자기 자신과 부딪힌 횟수

  • 그리퍼 실패율: 물체를 닫았지만 잡지 못한 비율

예를 들어 100번의 시뮬레이션 테스트를 수행해 63번 성공했다면 성공률은 63%다. 평균 위치 오차가 3cm라면 목표 근처까지는 갔지만 놓는 정확도가 부족할 수 있다. 충돌 횟수가 높다면 정책 출력에 안전 필터를 추가하거나, 시연 데이터에 더 부드러운 접근 동작을 넣어야 한다.

평가 코드는 다음처럼 구성할 수 있다.

def evaluate_policy(env, model, num_trials=100):
    results = []

    for trial in range(num_trials):
        info = run_policy_in_sim(env, model)
        results.append(info)

    success_count = sum(1 for r in results if r['success'])
    success_rate = success_count / num_trials

    final_errors = [r['final_error'] for r in results]
    mean_error = sum(final_errors) / len(final_errors)

    collisions = sum(r['collision_count'] for r in results)

    print(f'success_rate={success_rate:.2f}')
    print(f'mean_final_error={mean_error:.4f}')
    print(f'total_collisions={collisions}')

    return {
        'success_rate': success_rate,
        'mean_final_error': mean_error,
        'total_collisions': collisions,
    }

이때 info에 어떤 값을 넣을지는 환경 설계에 달려 있다. 중요한 것은 평가 기준을 미리 정해 두는 것이다. 기준 없이 데모 영상을 몇 번 보고 "괜찮아 보인다"라고 판단하면 모델 개선 방향을 잡기 어렵다.

모델이 이상하게 움직일 때 확인할 것

BC 실험은 처음부터 매끄럽게 성공하지 않는 경우가 많다. 모델이 아무 방향으로나 움직이거나, 그리퍼를 계속 닫고 있거나, 물체 근처에서 떨기만 할 수 있다. 이런 현상은 자연스럽다. 로봇 정책은 데이터, 모델, 실행 환경 중 하나만 어긋나도 바로 티가 난다.

문제가 생기면 다음 순서로 확인해 보자.

  • 입력과 정답 행동의 시간이 맞는지 확인한다.

  • 행동 벡터의 단위와 좌표계가 실행 환경과 같은지 확인한다.

  • 행동 값이 너무 크거나 작지 않은지 히스토그램을 그려 본다.

  • 학습 데이터와 검증 데이터의 손실이 실제로 내려가는지 확인한다.

  • 모델 예측 행동을 시각화해 사람이 보기에도 그럴듯한지 확인한다.

특히 시간 정렬은 아주 중요하다. state_t에 대해 action_t를 예측해야 하는데 실수로 action_{t-5}나 action_{t+5}를 정답으로 넣으면 모델은 엉뚱한 관계를 배운다. 사람이 이미 왼쪽으로 움직인 뒤의 상태를 보고 다시 왼쪽 행동을 정답으로 주면 로봇이 목표를 지나쳐 버릴 수 있다.

좌표계도 자주 문제를 만든다. 시연 데이터의 dx가 카메라 좌표계 기준인데 실행 환경에서는 로봇 베이스 좌표계 기준으로 해석하면 방향이 뒤집힐 수 있다. 사람은 컵을 향해 움직였는데 로봇은 컵에서 멀어지는 식이다. BC 모델은 좌표계를 이해해서 고쳐 주지 않는다. 데이터에 담긴 의미를 그대로 흉내 낼 뿐이다.

정책 출력에 안전 필터 붙이기

BC 모델은 사람 행동을 따라 배운다. 하지만 그것이 항상 안전하다는 뜻은 아니다. 데이터에 없는 상태에서는 이상한 출력을 낼 수 있고, 이미지가 흐리거나 물체가 가려지면 예측이 불안정해질 수 있다. 그래서 정책 출력은 바로 모터 명령으로 보내지 말고 안전 필터를 거치는 것이 좋다.

안전 필터는 정책이 낸 행동을 검사하고 필요하면 수정하거나 거부한다.

policy_action
→ 속도 제한
→ 작업 공간 제한
→ 충돌 검사
→ 그리퍼 명령 검사
→ 로봇 실행

예를 들어 엔드 이펙터 목표가 테이블 아래로 내려가려 하면 z 값을 최소 높이로 잘라낸다. 로봇팔이 허용된 작업 공간 밖으로 나가려 하면 행동을 멈춘다. MoveIt2 같은 경로 계획기를 함께 사용한다면 정책이 직접 관절 명령을 보내는 대신 작은 목표 자세를 제안하고, 실제 이동은 경로 계획과 충돌 검사를 거쳐 실행할 수 있다.

이 구조는 이 책 전체에서 반복해서 강조하는 원칙과 같다. AI 모델은 똑똑한 제안자가 될 수 있지만, 최종 실행은 안전한 로봇 시스템 안에서 이루어져야 한다. 특히 나중에 LLM 에이전트와 연결할 때도 같은 원칙이 적용된다. LLM은 작업을 분해하고 스킬을 호출하지만, 실제 로봇팔의 움직임은 검증된 제어 계층이 담당해야 한다.

작은 Pick 정책 만들기

이제 지금까지의 내용을 하나의 작은 프로젝트로 묶어 보자. 목표는 단순한 Pick 정책이다. 테이블 위에 놓인 물체에 엔드 이펙터가 접근하고, 충분히 가까워지면 그리퍼를 닫는 행동을 학습한다.

입력은 다음처럼 구성할 수 있다.

state_vec = [
  ee_x, ee_y, ee_z,
  object_x, object_y, object_z,
  gripper_open
]

출력은 다음과 같다.

action = [dx, dy, dz, gripper_close]

이 구조에서는 이미 객체 위치를 알고 있다고 가정한다. 객체 위치는 앞에서 배운 객체 검출, 깊이 추정, 좌표 변환 파이프라인으로 얻을 수 있다. 처음 BC를 구현할 때는 이처럼 인식 문제와 행동 학습 문제를 분리하는 것이 좋다. 이미지에서 물체를 찾는 것과 로봇팔을 움직이는 것을 동시에 학습시키면 어느 쪽이 문제인지 알기 어려워진다.

시연 데이터는 사람이 텔레오퍼레이션으로 수집한다. 여러 시작 위치에서 물체를 향해 부드럽게 접근하고, 그리퍼가 물체 양쪽에 오면 닫는다. 물체 위치도 조금씩 바꾼다. 항상 같은 위치의 물체만 보여 주면 모델은 특정 좌표를 외울 뿐이다.

테스트할 때는 물체를 학습 때와 비슷하지만 완전히 같지는 않은 위치에 놓는다. 모델이 물체 좌표를 보고 방향을 바꾸는지 확인한다. 성공률이 낮다면 더 다양한 시작 자세와 물체 위치를 수집한다. 로봇 학습에서 데이터 다양성은 모델 크기보다 먼저 확인해야 할 재료다.

BC 구현의 전체 파이프라인

Behavior Cloning의 전체 흐름을 한 번에 정리해 보자. 이 파이프라인은 이후 언어 조건 행동 학습과 로봇 파운데이션 모델을 이해하는 기초가 된다.

시연 수집
→ 로그 정리
→ 상태와 행동 정의
→ 데이터셋 생성
→ 학습/검증 분리
→ 정책 모델 설계
→ 손실 함수 선택
→ 배치 학습
→ 검증 손실 확인
→ 시뮬레이션 실행 평가
→ 실패 사례 분석
→ 데이터 추가 또는 모델 수정

여기서 가장 중요한 부분은 마지막 두 단계다. 한 번 학습하고 끝나는 것이 아니라, 실패한 장면을 보고 다시 데이터를 모으거나 모델 구조를 바꾼다. 로봇은 책상 위 코드처럼 조용히 실패하지 않는다. 컵을 밀고, 물체를 떨어뜨리고, 가끔은 자신 있게 엉뚱한 곳으로 간다. 그래서 실패를 관찰하고 학습 과정에 다시 넣는 루프가 필요하다.

BC는 단순하지만 강력하다. 사람이 잘하는 행동을 충분히 많이 보여 줄 수 있다면, 로봇은 복잡한 보상 설계 없이도 그럴듯한 정책을 배울 수 있다. 또한 이 구조는 훨씬 큰 모델로 확장된다. 상태에 언어 명령을 추가하면 언어 조건 행동 학습이 되고, 다양한 로봇과 다양한 작업의 대규모 데이터를 모으면 로봇 파운데이션 모델의 출발점이 된다.

지금 우리는 작은 지도학습 모델을 만들었을 뿐이다. 하지만 그 안에는 최신 로봇 AI의 핵심 구조가 들어 있다. 관측을 받고, 행동을 예측하고, 실행 결과를 다시 관측한다. 차이가 있다면 규모와 데이터의 다양성이다.

다음 장에서는 이 단순하고 매력적인 방법이 왜 현실에서 자주 흔들리는지 살펴본다. 특히 작은 오차가 어떻게 커지는지, 왜 데이터 분포를 벗어난 순간 로봇이 당황하는지, 그리고 실패 데이터가 왜 중요한지 알아볼 것이다.

책 목록

AI 로봇 에이전트 만들기: 비전·로봇팔·학습·LLM으로 자연어 명령을 실행하는 로봇

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기