Devin.KR

잡음 줄이기 - 이동평균·저역통과 필터

개발자KR 조회 3

이 장에서 배우는 것

거리센서나 엔코더에서 읽은 값은 매 순간 조금씩 튄다. 로봇이 완전히 정지해 있어도 초음파 센서는 38cm, 41cm, 37cm처럼 흔들리는 값을 내놓고, 바퀴 속도도 정확히 일정하지 않고 잔파동을 보인다. 앞 장에서는 이런 흔들림이 왜 생기는지(엔코더 해상도, IMU 드리프트, 거리센서의 반사 잡음)를 다뤘다면, 이 장에서는 그 흔들림을 계산으로 줄이는 두 가지 방법 — 이동평균 필터와 저역통과 필터(low-pass filter) — 를 다룬다.

  • 이동평균 필터의 계산 원리와 numpy 구현 방법을 익힌다
  • 지수이동평균 형태의 저역통과 필터를 재귀식으로 구현한다
  • 필터 세기(창 크기, alpha)가 잡음 감소량과 반응 지연에 미치는 영향을 수치로 비교한다
  • 상황(정지 근접 감지, 속도 피드백 등)에 맞는 필터 파라미터를 고르는 기준을 세운다

문제 상황

2바퀴 차동 구동 로봇에 전방 거리센서를 달고 "40cm 이내면 정지"라는 규칙을 넣었다고 하자. 로봇이 벽에 가까워지는 도중에도 센서 값은 42, 38, 44, 36처럼 흔들리기 때문에, 40cm 문턱을 넘나들며 정지와 재출발을 반복하는 "떨림" 현상이 나타난다. 반대로 필터를 무작정 세게 걸어 잡음을 완전히 없애려 하면, 이번에는 로봇이 벽에 너무 가까워진 뒤에야 정지 신호가 도착해 충돌 직전에야 반응하는 문제가 생긴다. 즉 잡음을 줄이는 일은 공짜가 아니다 — 부드러움을 얻는 대신 반응 속도(지연)를 내준다. 이 장의 목표는 그 대가를 눈으로 확인하고, 상황에 맞게 조절하는 것이다.

이동평균 필터

이동평균(moving average) 필터는 가장 최근 window개의 측정값을 산술평균해 하나의 출력을 만든다. 새 측정값이 들어올 때마다 평균에 들어가는 표본의 창이 한 칸씩 옆으로 이동하기 때문에 "이동"평균이라 부른다. 창에 들어간 값들이 서로 다른 방향으로 흔들리는 잡음이라면, 평균을 내는 과정에서 잡음끼리 상쇄되어 결과가 원래 신호보다 훨씬 매끄러워진다. 표본이 서로 독립적인 잡음이라면 표준편차는 대략 1/sqrt(window)배로 줄어든다 — 창을 4배로 늘리면 잡음은 절반으로 준다.

단점은 계산에 최근 window개 값을 전부 기억해야 하고, 값이 실제로 바뀐 뒤에도 창 안에 남아있는 "옛날 값"들이 평균을 끌어당겨 출력이 뒤늦게 따라온다는 점이다. 아래 그림은 창 크기 3인 이동평균이 세 개의 원시 측정값을 평균해 하나의 출력을 만드는 과정을 보여준다.

이동평균 필터는 최근 몇 개 측정값을 평균해 잡음을 줄인다

저역통과 필터와 지연-부드러움의 균형

지수이동평균(EMA) 방식

실무에서 가장 널리 쓰이는 저역통과 필터는 다음 재귀식으로 정의되는 지수이동평균(exponential moving average, EMA)이다.

y[i] = alpha * x[i] + (1 - alpha) * y[i-1]

alpha는 0과 1 사이의 값으로, 새 측정값 x[i]를 얼마나 반영할지를 정한다. 이 식을 과거로 계속 풀어 쓰면 y[i] = alpha * x[i] + alpha*(1-alpha) * x[i-1] + alpha*(1-alpha)^2 * x[i-2] + ... 형태가 되어, 오래된 값일수록 (1-alpha)의 거듭제곱만큼 가중치가 지수적으로 줄어든다. 그래서 "지수"이동평균이라 부른다. alpha가 크면 가장 최근 값의 계수 자체가 커서 잡음이 거의 그대로 남고, alpha가 작으면 과거 값이 천천히 잊혀 출력이 매끄러워지는 대신 변화에 느리게 반응한다.

이동평균과 달리 EMA는 직전 출력값 y[i-1] 하나만 기억하면 되므로 메모리와 계산량이 거의 들지 않는다. 이 때문에 마이크로컨트롤러처럼 자원이 제한된 환경에서 특히 자주 쓰인다.

필터 세기와 지연의 관계

이동평균의 창 크기와 EMA의 alpha는 서로 다른 파라미터지만 같은 역할을 한다 — 둘 다 "얼마나 과거를 오래 기억하는가"를 조절한다. 창이 크거나 alpha가 작을수록 평균에 들어가는 과거 표본이 많아져 잡음은 줄지만, 실제 값이 바뀐 뒤 그 변화가 출력에 완전히 반영되기까지 걸리는 시간(지연)도 함께 늘어난다. 아래 그림은 값이 계단 형태로 뚝 떨어지는 상황에서, 약한 필터(작은 창·큰 alpha)와 강한 필터(큰 창·작은 alpha)가 각각 얼마나 빠르게 새 값을 따라가는지 비교한 것이다.

필터를 세게 걸수록 잡음은 줄지만 값 변화에 대한 반응은 늦어진다
이동평균과 저역통과 필터 비교
항목이동평균(window)저역통과(alpha)
기억해야 하는 과거 값window개 전부 저장직전 출력 1개만 저장
필터를 강하게 걸 때window를 키운다 → 부드럽지만 지연 커짐alpha를 줄인다 → 부드럽지만 지연 커짐
필터를 약하게 걸 때window를 줄인다 → 반응 빠르지만 잡음 남음alpha를 키운다 → 반응 빠르지만 잡음 남음
계산 비용매 스텝 window개 값의 합산(단순 구현 시)매 스텝 곱셈 두 번, 덧셈 한 번

완성 코드

filters_demo.py

import numpy as np


def moving_average(signal, window):
    pad = np.full(window - 1, signal[0])
    padded = np.concatenate([pad, signal])
    kernel = np.ones(window) / window
    return np.convolve(padded, kernel, mode="valid")


def low_pass(signal, alpha):
    out = np.empty_like(signal, dtype=float)
    out[0] = signal[0]
    for i in range(1, len(signal)):
        out[i] = alpha * signal[i] + (1 - alpha) * out[i - 1]
    return out


def mean_abs_error(estimate, true):
    return float(np.mean(np.abs(estimate - true)))


def main():
    raw = np.array([78, 82, 79, 81, 77, 83, 48, 52, 47, 53, 49, 51], dtype=float)
    true = np.array([80.0] * 6 + [50.0] * 6)

    ma3 = moving_average(raw, 3)
    lp5 = low_pass(raw, 0.5)

    print(f"{'step':>4} {'true':>6} {'raw':>6} {'MA3':>6} {'LP0.5':>7}")
    for i in range(len(raw)):
        print(f"{i:4d} {true[i]:6.1f} {raw[i]:6.1f} {ma3[i]:6.2f} {lp5[i]:7.2f}")

    print()
    print("필터별 평균절대오차(MAE, 참값과의 차이 평균)")
    candidates = {
        "raw": raw,
        "MA3": ma3,
        "MA5": moving_average(raw, 5),
        "LP0.5": low_pass(raw, 0.5),
        "LP0.2": low_pass(raw, 0.2),
    }
    for name, series in candidates.items():
        print(f"{name:>6}: {mean_abs_error(series, true):.2f}")


if __name__ == "__main__":
    main()

줄별 해설

moving_average: window - 1개만큼 첫 값을 복제해 배열 앞쪽을 채운 뒤 np.convolve를 mode="valid"로 적용한다. 패딩 없이 바로 mode="valid"를 쓰면 출력 길이가 window - 1만큼 짧아져 원본 배열과 인덱스가 어긋나므로, 앞부분을 채워 길이를 맞춘다. 커널이 [1/window, ..., 1/window]로 좌우 대칭이라 합성곱이 뒤집혀도 결과는 이동평균과 같다.

low_pass: 재귀식을 그대로 for문으로 옮긴 것이다. numpy는 배열 전체를 한 번에 계산하는 벡터 연산에 강하지만, EMA는 y[i]가 y[i-1]에 의존하는 순차 계산이라 벡터화가 되지 않는다. 실제 로봇 제어 루프에서도 이 필터는 매 주기 한 번씩 이런 형태로 호출된다.

mean_abs_error: 필터 출력이 참값에서 평균적으로 얼마나 떨어져 있는지를 하나의 숫자로 요약한다.

main: raw는 참값이 80에서 50으로 뚝 떨어지는 상황(6번째 값부터)을 흉내 낸 잡음 섞인 측정값이다. 실제 로봇이라면 이 값은 센서에서 매 주기 들어오겠지만, 이 장에서는 결과를 정확히 재현할 수 있도록 고정된 숫자 배열을 사용했다. ma3와 lp5를 계단별로 출력해 반응 속도를 비교하고, 이어서 candidates 딕셔너리에 다섯 가지(원시값, 이동평균 두 창 크기, 저역통과 두 alpha)를 모아 MAE로 한 번에 비교한다.

실행 결과

$ python3 filters_demo.py
step   true    raw    MA3   LP0.5
   0   80.0   78.0  78.00   78.00
   1   80.0   82.0  79.33   80.00
   2   80.0   79.0  79.67   79.50
   3   80.0   81.0  80.67   80.25
   4   80.0   77.0  79.00   78.62
   5   80.0   83.0  80.33   80.81
   6   50.0   48.0  69.33   64.41
   7   50.0   52.0  61.00   58.20
   8   50.0   47.0  49.00   52.60
   9   50.0   53.0  50.67   52.80
  10   50.0   49.0  49.67   50.90
  11   50.0   51.0  51.00   50.95

필터별 평균절대오차(MAE, 참값과의 차이 평균)
   raw: 2.00
   MA3: 3.19
   MA5: 5.50
 LP0.5: 2.90
 LP0.2: 7.79

6번째 줄(참값이 50으로 바뀐 직후)을 보면 MA3=69.33, LP0.5=64.41로 둘 다 아직 참값에서 멀리 떨어져 있다. 그리고 놀랍게도 MAE만 보면 가공하지 않은 raw가 강하게 필터링한 MA5, LP0.2보다 오히려 오차가 작다. 이 데이터처럼 값이 실제로 뚝 바뀌는 상황이 자주 있다면, 필터를 세게 걸수록 무조건 좋아지는 것이 아니라는 뜻이다.

필터별 평균절대오차(MAE)와 특징
필터MAE특징
raw(원시값)2.00잡음은 그대로지만 계단 변화에 즉시 반응
MA33.19잡음은 줄지만 변화 직후 몇 스텝 오차가 커짐
MA55.50더 매끄럽지만 변화 반영이 늦어 오차 누적
LP(alpha=0.5)2.90MA3와 비슷한 지연으로 잡음을 어느 정도 줄임
LP(alpha=0.2)7.79매우 매끄럽지만 변화 반응이 크게 늦음

실무에서 자주 틀리는 것

창을 무작정 크게 잡으면 좋다는 오해

# 틀린 코드: 상황과 무관하게 큰 창을 고정으로 사용
filtered = moving_average(distance_readings, 21)
# 고친 코드: 로봇 속도에 따라 창 크기를 다르게 선택
window = 5 if robot_speed > 0.3 else 15
filtered = moving_average(distance_readings, window)

창이 크면 정지 상태에서는 안정적이지만, 빠르게 움직이며 장애물에 접근할 때는 십여 스텝 뒤에야 반응해 충돌 위험이 커진다. 속도가 빠를수록 창을 줄여 반응성을 우선해야 한다.

alpha가 클수록 부드러워진다는 착각

# 틀린 코드: 부드럽게 만들려고 alpha를 크게 잡음
filtered = low_pass(speed_readings, alpha=0.95)
# 고친 코드: alpha가 작을수록 과거 값 비중이 커져 부드럽다
filtered = low_pass(speed_readings, alpha=0.1)

alpha는 "새 값을 얼마나 즉시 반영할지"의 비율이다. 0.95처럼 1에 가까우면 직전 출력이 거의 버려지고 매 스텝 새 측정값이 그대로 나오는 것과 다르지 않다.

패딩 없이 convolve부터 쓰기

# 틀린 코드: 배열 길이가 줄어드는 것을 놓침
filtered = np.convolve(raw, np.ones(5) / 5, mode="valid")
# 고친 코드: 앞부분을 패딩해 원본과 길이를 맞춤
pad = np.full(4, raw[0])
filtered = np.convolve(np.concatenate([pad, raw]), np.ones(5) / 5, mode="valid")

mode="valid"는 배열 길이를 len(raw) - window + 1로 줄인다. 이후 filtered[i]가 raw[i]와 같은 시점을 가리킨다고 착각하고 비교하면 인덱스가 계속 어긋난다.

순환값(각도)에 필터를 그대로 적용

# 틀린 코드: 359도와 1도를 평균해 180도라는 엉뚱한 값이 나옴
heading_avg = moving_average(heading_deg, 3)
# 고친 코드: sin, cos으로 나눠 필터링한 뒤 각도로 복원
sin_avg = moving_average(np.sin(np.radians(heading_deg)), 3)
cos_avg = moving_average(np.cos(np.radians(heading_deg)), 3)
heading_avg = np.degrees(np.arctan2(sin_avg, cos_avg)) % 360

IMU에서 얻은 헤딩각은 0도와 360도가 사실상 같은 방향인 순환값이다. 산술평균을 그대로 내면 경계 근처에서 실제 방향과 정반대의 값이 나올 수 있다.

한눈에 보기

이 장의 핵심 요점
개념핵심 요점
이동평균 필터최근 window개 값을 평균, 표본이 많을수록 잡음은 1/sqrt(window)배로 감소
저역통과 필터(EMA)y[i]=alpha*x[i]+(1-alpha)*y[i-1], 과거값 1개만 기억하면 됨
지연-부드러움 균형필터를 세게 걸수록(큰 window, 작은 alpha) 잡음은 줄지만 변화 반응이 늦어짐
파라미터 선택 기준값이 급변하는 상황이 잦으면 약한 필터, 정적인 값이면 강한 필터가 유리

연습 문제

  1. 이동평균 필터의 창 크기를 3에서 9로 늘리면 잡음 감소와 반응 지연이 각각 어떻게 바뀌는지 한 문장씩 설명하라.
  2. 저역통과 필터 y[i] = alpha * x[i] + (1 - alpha) * y[i-1]에서 alpha=0.9와 alpha=0.1 중 어느 쪽이 최근 측정값의 비중을 더 크게 반영하는가? 재귀식을 풀어 쓴 형태를 근거로 설명하라.
  3. 본문 실행 결과에서 참값이 80에서 50으로 바뀐 직후(6번째 줄)의 MA3와 LP0.5 값을 비교하라. 어느 필터가 변화를 더 빨리 따라가는가?
  4. 로봇의 헤딩각(0~360도)에 이동평균 필터를 그대로 적용하면 안 되는 이유를 설명하고 대안을 제시하라.

정답과 해설

1. 창이 커지면 평균에 들어가는 표본 수가 늘어 잡음(표준편차)은 대략 1/sqrt(window)배로 줄어든다. 반면 값이 실제로 바뀐 뒤에도 창 안에 옛 값이 남아있는 기간이 창 크기에 비례해 늘어나므로 반응 지연도 함께 커진다.

2. alpha=0.9 쪽이 최근 값의 비중이 더 크다. 재귀식을 과거로 풀어 쓰면 y[i] = alpha*x[i] + alpha*(1-alpha)*x[i-1] + alpha*(1-alpha)^2*x[i-2] + ... 형태가 된다. alpha가 크면 k=0항(가장 최근 값)의 계수 alpha 자체가 크고 (1-alpha)가 작아 과거 항의 계수가 빠르게 줄어든다. alpha=0.1이면 (1-alpha)=0.9로 과거 값이 천천히 잊혀 최근 값의 비중이 상대적으로 작다.

3. 6번째 줄에서 MA3=69.33, LP0.5=64.41로 LP0.5가 참값 50에 더 가깝다. alpha=0.5인 저역통과 필터는 매 스텝 절반씩 새 값을 반영해, 창 3개 값을 균등하게 평균하는 MA3보다 변화 직후 더 빨리 반응한다. MAE 표에서도 LP0.5=2.90이 MA3=3.19보다 작아 같은 결론을 확인할 수 있다.

4. 각도는 359도와 1도가 실제로는 2도밖에 차이 나지 않는 순환값인데, 단순 산술평균을 내면 (359+1)/2=180으로 계산되어 실제 방향과 정반대 값이 나온다. 대안은 각도를 sin, cos 성분으로 변환해 각각 필터링한 뒤 atan2(평균 sin, 평균 cos)로 각도를 복원하는 것이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.