워크스루 01 — 칼만 필터로 잡음 속 위치 추정

왜 하필 칼만 필터냐면요

자율주행, 드론, 로봇 청소기, 스마트워치의 걸음 수 추정. 이 모두의 안쪽에는 같은 문제가 깔려 있습니다 — 센서는 거짓말을 합니다. 정확히는, 진짜 값에 잡음이 섞여서 들어옵니다. GPS는 몇 미터씩 떨리고, 가속도 센서는 미세하게 흔들리고, 라이다는 가끔 튀는 값을 뱉습니다.

그런데 우리는 그 떨리는 데이터로 “지금 진짜 어디 있는지”를 알아내야 합니다. 그 격차를 메우는 가장 고전적이면서 지금도 현역인 도구가 칼만 필터입니다. 1960년대 아폴로 미션의 항법에 쓰였고, 60년이 지난 지금도 거의 모든 위치 추정 시스템 어딘가에 들어 있습니다.

코드는 의외로 짧습니다. 그런데 그 짧은 코드 안에 확률·선형대수·재귀 추정이라는 깊은 개념이 다 들어 있어서, AI와 함께 한 줄씩 풀어보기에 이만한 교재가 없습니다.

준비물

  • Octave 설치 (octave --version으로 확인)
  • 외부 패키지는 필요 없습니다. 기본 행렬 연산만 씁니다.

실행 환경은 Jupyter 노트북(Octave 커널)이 가장 편하고, 그냥 octave --no-gui kalman.m로도 충분합니다.

개념 — 한 단락 요약

물체가 1차원 직선 위에서 등속도로 움직인다고 합시다. 우리는 위치만 측정할 수 있고, 측정에는 잡음이 섞입니다. 칼만 필터는 두 가지 정보를 결합해 진짜 위치를 추정합니다.

  1. 예측 — 직전까지 알던 위치와 속도로 “지금쯤 여기 있겠지”라고 모델로 점을 칩니다.
  2. 업데이트 — 실제 측정값이 들어오면, 모델 예측과 측정값을 두 정보의 불확실성에 따라 가중치를 두고 섞어서 새 추정값을 만듭니다.

신기한 건, 이 두 단계를 반복하다 보면 속도를 직접 측정한 적이 없는데도 시간이 지나면서 속도까지 추정된다는 겁니다. “아니, 어떻게요?”라는 생각이 드셨다면 — 그게 바로 AI에게 물어보기 좋은 질문입니다.

단계별로 가봅시다

1단계 — 진짜 움직임과 잡음 측정 만들기

먼저 우리가 “답을 이미 아는” 시뮬레이션 데이터를 만듭니다. 칼만 필터의 추정값이 진짜 값에 얼마나 가까운지 나중에 비교해야 하니까요.

% kalman.m
clear; close all;

% 시뮬레이션 파라미터
N  = 100;          % 시간 스텝 수
dt = 0.1;          % 한 스텝의 시간 [초]
v_true = 1.0;      % 진짜 속도 [m/s]
sigma_meas = 1.0;  % 측정 잡음의 표준편차 [m]

% 진짜 위치: x(t) = v_true * t
t = (0:N-1)' * dt;
x_true = v_true * t;

% 잡음 섞인 측정
randn('state', 42);     % 재현 가능하게
z = x_true + sigma_meas * randn(N, 1);

% 일단 그림으로 확인
figure;
plot(t, x_true, 'g-', 'LineWidth', 2); hold on;
plot(t, z, 'b.', 'MarkerSize', 8);
legend('진짜 위치', '잡음 측정');
xlabel('시간 [s]'); ylabel('위치 [m]');
title('진짜 위치 vs 잡음 측정');
grid on;

여기까지 돌려서 그래프가 떴다면, 녹색 직선 위로 파란 점들이 들쭉날쭉 흩어져 있는 그림이 보일 겁니다. 칼만 필터가 할 일이 바로 그 점들로부터 녹색 직선을 복원하는 거라고 생각하시면 됩니다.

2단계 — 칼만 필터 본체

여기가 핵심입니다. 코드는 짧지만 한 줄 한 줄에 의미가 있습니다.

% --- 칼만 필터 ---

% 상태 벡터: [위치; 속도]
% 측정은 위치만 본다.

% 시스템 모델
A = [1 dt; 0 1];     % 상태 전이: x_new = x + v*dt, v_new = v
H = [1 0];           % 측정 행렬: 위치만 본다
Q = 0.01 * eye(2);   % 모델 잡음 공분산 (작게 — 모델을 어느 정도 믿는다)
R = sigma_meas^2;    % 측정 잡음 분산

% 초기 추정 (위치 0, 속도 0이라고 가정 — 일부러 틀린 초기값)
x_hat = [0; 0];
P     = eye(2);      % 초기 공분산 (불확실성 큼)

x_estimates = zeros(N, 1);
v_estimates = zeros(N, 1);

for k = 1:N
  % --- 예측 단계 ---
  x_hat = A * x_hat;
  P     = A * P * A' + Q;

  % --- 업데이트 단계 ---
  y = z(k) - H * x_hat;          % 측정 잔차 (innovation)
  S = H * P * H' + R;             % 잔차 공분산
  K = P * H' / S;                 % 칼만 이득

  x_hat = x_hat + K * y;
  P     = (eye(2) - K * H) * P;

  x_estimates(k) = x_hat(1);
  v_estimates(k) = x_hat(2);
end

행렬 크기를 한 번 짚어봅시다. A는 2×2, H는 1×2, K는 2×1이 됩니다. 차원이 안 맞으면 곧장 에러가 나기 때문에, 의외로 디버깅은 빠릅니다.

3단계 — 결과 비교

figure;
subplot(2, 1, 1);
plot(t, x_true, 'g-', 'LineWidth', 2); hold on;
plot(t, z, 'b.', 'MarkerSize', 6);
plot(t, x_estimates, 'r-', 'LineWidth', 1.5);
legend('진짜 위치', '잡음 측정', '칼만 추정');
xlabel('시간 [s]'); ylabel('위치 [m]');
title('위치 추정');
grid on;

subplot(2, 1, 2);
plot(t, v_true * ones(N, 1), 'g-', 'LineWidth', 2); hold on;
plot(t, v_estimates, 'r-', 'LineWidth', 1.5);
legend('진짜 속도', '칼만 추정 속도');
xlabel('시간 [s]'); ylabel('속도 [m/s]');
title('속도 추정 (직접 측정 안 했음!)');
grid on;

기대하는 그림은 이렇습니다:

  • 위 그래프: 빨간선(추정)이 처음엔 0에서 시작해서 (초기값을 일부러 0으로 줬으니까요), 빠르게 녹색선(진짜)에 붙습니다. 그 뒤로는 파란 점들의 떨림에 비해 훨씬 매끄럽게 진짜 값을 따라갑니다.
  • 아래 그래프: 빨간선이 0에서 출발해 1.0 근처로 수렴해 갑니다. 속도는 측정한 적이 없는데도요.

이 두 번째 그래프가 칼만 필터의 진짜 매력입니다.

4단계 — 직접 변형해 봅시다

다음 변경을 하나씩 해보면서 그래프가 어떻게 달라지는지 관찰해 보세요:

  • sigma_meas를 0.1로 줄여봅니다 (측정 잡음 작음). 추정이 어떻게 달라지나요?
  • sigma_meas를 5.0으로 키워봅니다. 칼만 필터가 측정을 얼마나 “안 믿게” 되나요?
  • 진짜 모델을 등속도가 아닌 가속 운동(x_true = 0.5 * t.^2)으로 바꿔봅니다. 같은 필터가 따라갈까요, 못 따라갈까요? 왜죠?
  • Q 행렬의 값을 키워봅니다 (모델을 덜 믿음). 어떻게 달라지나요?

이 변형들이 칼만 필터의 직관을 머리에 박아줍니다.

AI에게 물어보기 좋은 시점

워크스루를 그냥 따라 치는 것보다 각 단계마다 AI에게 물어보면서 가는 게 학습 효과가 훨씬 큽니다. 잘 통했던 프롬프트들입니다.

1단계 직후:

“이 코드에서 randn('state', 42)는 왜 필요한가요? 빼면 어떻게 되나요? 그리고 sigma_meas를 표준편차라고 부르는 이유와, 측정값이 진짜 값에서 평균적으로 얼마나 떨어지는지의 관계를 설명해 주세요.”

2단계 칼만 본체 작성 직후:

“방금 짠 칼만 필터 코드에서 K(칼만 이득)는 어떤 의미인가요? K가 0에 가까울 때와 1에 가까울 때, 필터는 어떻게 다르게 동작하나요? 그리고 K는 시간이 지나면서 어떻게 변할 것 같나요? Octave로 K의 시간에 따른 변화를 추가로 그려서 보여주세요.”

3단계 그래프를 본 직후:

“속도는 직접 측정한 적이 없는데도 칼만 필터가 속도를 추정해 내는 것이 신기합니다. 수학적으로 어떻게 가능한 일인지, 이 코드의 어느 줄에서 그 마법이 일어나는지 짚어 주세요.”

4단계 변형 후:

Q 값을 100배 키웠더니 추정이 더 떨리는데, 동시에 모델 변화(예: 가속 운동)에는 더 빨리 적응합니다. 이 트레이드오프를 한 문단으로 설명해 주세요. 실제 응용에서는 어떻게 결정하나요?”

막힐 만한 곳

  • /(나눗셈)에서 차원 에러: K = P * H' / S 줄은 S가 스칼라(1×1)일 때는 잘 돕니다. 측정이 다차원이 되면 pinv(S)로 바꿔야 안전합니다.
  • 추정이 발산함: Q를 너무 크게 줬거나, 초기 공분산 P를 0으로 준 경우입니다. P를 너무 작게 시작하면 필터가 자기 추정만 믿고 측정을 무시합니다.
  • 그래프에 한국어 라벨이 깨짐: 글꼴 문제입니다. 급하면 라벨을 영어로('true position' 등) 바꾸는 게 빠릅니다.
  • 빨간 선이 처음에 천천히 따라옴: 정상입니다. 초기값을 [0; 0]으로 일부러 틀리게 줬기 때문에, 필터가 현실에 적응하는 데 몇 스텝이 걸리는 겁니다.

확장 아이디어

다 끝나셨다면 이런 쪽으로 가지를 뻗을 수 있습니다:

  • 2D 위치 추정: 상태를 [x; y; vx; vy]로 확장. GPS 잡음 시뮬레이션과 더 가까워집니다.
  • 확장 칼만 필터 (EKF): 비선형 시스템(예: 회전하는 물체)용. AI에게 “선형 칼만과 EKF의 차이를 코드 레벨로 보여줘”라고 하면 좋은 후속편이 나옵니다.
  • 실제 데이터 적용: 스마트폰 GPS 로그 같은 CSV가 있으면 같은 필터로 매끄럽게 만들어볼 수 있습니다.

내가 해본 기록

2026-07-26 — AI 에이전트와 함께 첫 실행

스크립트: wt01_kalman.m, 그래프: wt01_kalman.png, wt01_kalman_accel.png (환경: Linux + Octave 8.4, headless)

칼만 추정(빨강)이 잡음 측정(파랑) 속에서 참 궤적(초록)을 복원하는 모습

  • 핵심 결과: 측정값 RMSE 1.128 m → 칼만 추정 RMSE 0.403 m. 잡음의 2/3가 사라졌습니다. 그리고 속도는 한 번도 측정하지 않았는데 최종 추정이 0.998 m/s (참값 1.0). 문서가 예고한 그 마법이 진짜로 일어납니다.
  • 칼만 이득의 수렴: 위의 AI 프롬프트 제안대로 K(1)을 시간에 따라 그려봤습니다. 0.505에서 시작해 0.159로 수렴하는데, 필터가 처음엔 측정을 많이 믿다가 자기 모델에 확신이 생기면서 측정 가중치를 줄여가는 과정이 숫자로 보입니다.
  • 변형 실험 (등속 필터로 가속 목표 추적): 참 궤적을 x = 0.5t²로 바꾸니 Q=0.01(모델 과신)에서는 후반부 RMSE 0.912 m로 계속 뒤처졌고, Q=1.0으로 키우니 0.636 m로 개선됐습니다. “Q는 모델을 얼마나 의심할지”라는 트레이드오프가 그래프에서 바로 보입니다 — 대신 Q가 크면 추정이 잡음을 더 탑니다.
  • 확장도 해봤습니다 — 2D 위치 추정 (wt01_ext_kalman2d.m): 상태를 [x; y; vx; vy]로 넓히고 GPS급 잡음(σ=1.5 m)을 줬습니다. 측정 RMSE 2.06 m → 칼만 0.72 m. 속도 추정(후반 평균)은 vx 1.068 / vy 0.466 (참값 1.0 / 0.5)인데, 1D 때보다 수렴이 눈에 띄게 느립니다. 같은 잡음으로 두 배의 상태를 추정해야 하니 당연한 대가겠지요. 측정이 다차원이라 “막힐 만한 곳”의 팁대로 칼만 이득 계산에 pinv(S)를 썼습니다.
  • 막힌 곳: 없었습니다. 문서 예고대로 행렬 차원(2×2, 1×2, 2×1)만 맞으면 한 번에 돕니다.
  • 남은 확장: EKF와의 코드 레벨 비교.