센서 잡음과 오차 지표
80분 안팎
학습 목표
참값·측정값 쌍으로 편향과 RMSE를 계산합니다.
개념
참값이 없으면 측정 오차를 계산할 수 없습니다
앞 모듈의 로봇은 이상적인 HAL 자세를 읽으며 경로를 따라갔습니다. 이제 바퀴가 보고하는 이동량과 시뮬레이터가 알고 있는 위치를 나눕니다. estimated_pose는 제어기에 제공하는 추정값이고 truth_pose는 평가에만 쓰는 참값입니다. 제어기가 참값을 읽으면 센서가 나빠져도 제어 결과가 변하지 않아 실험이 잘못된 안심을 줄 수 있습니다. 이 레슨은 두 값을 같은 시각에 짝지어 오차를 수치로 설명하는 출발점입니다.
이번 모듈의 브라우저 코드는 Python 3 표준 라이브러리만 사용합니다. JSON 객체 하나를 입력하고 지정한 텍스트 또는 JSON 결과를 출력합니다. 로컬 실습은 zip을 별도 폴더에 풀고 bash check.sh로 실행하며 미션에는 gcc도 필요합니다. 시각은 같은 가상 시계의 초, 위치와 거리는 m, 자세 방향은 rad입니다. 외부 패키지나 ROS 설치 없이 계산 계약을 검증하고 실제 장치의 시간 동기화는 인계 대상으로 남깁니다.
오차의 부호부터 정합니다
한 쌍은 [참값,측정값]입니다. 오차 e는 측정값에서 참값을 뺀 값으로 정의합니다. 참값 1.00m, 측정값 1.02m이면 e는 +0.02m입니다. 순서를 거꾸로 쓰면 RMSE는 그대로지만 편향의 부호가 뒤집힙니다. 실험 보고에는 빼기 순서와 단위를 적습니다. 위치가 달라지는 주행에서는 같은 인덱스라는 이유만으로 짝짓지 않고 시각도 같다는 조건을 확인해야 합니다.
bias는 모든 e의 평균입니다. 같은 방향으로 치우치는 정도를 보여 주며 단위는 m입니다. 오차가 +0.02와 -0.02로 번갈아 나오면 bias는 0입니다. 그렇다고 개별 측정이 정확하지는 않습니다. 반대로 매번 +0.02이면 표본 사이 흔들림은 없지만 bias는 남습니다. 센서 설치 위치를 잘못 입력한 경우와 매번 흔들리는 거리 입력은 서로 다른 수정이 필요하므로 평균 하나로 둘을 합치지 않습니다.
제곱한 뒤 평균하고 마지막에 제곱근을 씁니다
RMSE는 sqrt(sum(e²)/N)입니다. 양수와 음수 오차가 서로 지워지는 것을 막고 큰 오차에 더 큰 비중을 줍니다. 제곱 평균의 단위는 m²이고 제곱근을 취하면 m로 돌아옵니다. 평균을 먼저 낸 뒤 제곱하면 bias의 절댓값이 될 뿐 RMSE가 아닙니다. 리스트의 평균 오차와 제곱 평균을 별개 변수로 두면 두 계산을 혼동하는 일을 줄일 수 있습니다.
이번 실습의 spread는 sqrt(sum((e-bias)²)/N)입니다. 주어진 기록 자체의 퍼짐을 기술하므로 분모는 N입니다. 미지의 모집단 분산을 불편 추정하려고 N-1을 사용하는 통계 문제와 목적이 다릅니다. N이 1인 입력에서는 spread가 0이지만 센서가 언제나 완벽하다는 뜻은 아닙니다. 한 표본으로 장기 분포를 판단할 근거가 부족하다는 한계를 함께 써야 합니다.
동일한 표본에서는 RMSE²=bias²+spread² 관계가 성립합니다. +0.02만 반복하면 spread는 0이고 RMSE는 0.02입니다. ±0.02가 같은 횟수면 bias는 0이고 RMSE와 spread가 모두 0.02입니다. 이 두 fixture를 함께 검사하면 구현이 편향과 잡음을 구분하는지 알 수 있습니다. 실수 계산에서 마지막 자릿수 차이가 생길 수 있어 출력은 소수 여섯 자리로 고정합니다.
위치 추정의 누적 오차를 따로 봅니다
미션의 평면 위치 RMSE는 각 시각의 (estimated_x-truth_x)²+(estimated_y-truth_y)²를 평균한 뒤 제곱근을 취합니다. 두 축을 합친 거리 오차이며 방향각 오차와는 다른 지표입니다. m와 rad를 한 합에 섞지 않습니다. 방향이 조금만 틀려도 장거리 이동에서 위치가 크게 벌어질 수 있으므로 위치 RMSE와 함께 시작·종료 자세를 기록해 원인을 찾습니다.
가상 엔코더는 PWM으로 구한 좌우 바퀴 이동량에 잡음을 더합니다. 0.02m 설정은 각 바퀴가 한 tick 동안 보고하는 이동량 오차의 표준편차입니다. 매 tick의 위치에 0.02m를 더한다는 뜻도, 최종 위치 표준편차가 0.02m라는 뜻도 아닙니다. 두 바퀴 오차의 차이는 회전에, 평균은 전진에 영향을 줍니다. 이동량을 누적하는 동안 자세 오차가 쌓이는 과정은 단발 거리 측정과 구분합니다.
실험 조건을 통제합니다
난수를 쓰는 실험은 seed와 생성 순서를 기록합니다. 같은 seed라도 한 조건에서 난수 호출을 더 하면 이후 표본이 달라질 수 있습니다. 미션은 조건마다 Random 인스턴스를 새로 만들고 같은 횟수의 좌우 표본을 생성합니다. 무잡음·잡음·잡음과 지연을 비교할 때 주행 입력도 같은 직진 명령으로 고정합니다. 제어기가 조건마다 다른 경로를 택하면 센서만 바꾼 비교가 아니게 됩니다.
seed를 고정한 한 번의 실험은 구현 회귀를 확인하는 데 유용합니다. 제품 성능을 대표하는 충분한 통계라고 주장하지 않습니다. 실제 평가에서는 여러 seed, 경로, 표면, 속도를 정하고 결과의 분포와 실패 횟수를 함께 볼 필요가 있습니다. 이번 레슨에서는 계산을 직접 검산할 수 있는 작은 목록으로 지표 구현부터 확인하고 더 넓은 실험 계획은 다음 기록 재생 모듈로 넘깁니다.
유효하지 않은 기록을 성공처럼 만들지 않습니다
빈 목록은 평균의 분모가 없으므로 ERROR입니다. null·문자열·길이가 다른 쌍도 입력 계약 오류입니다. NaN과 무한대가 들어오면 지표 전체가 의미 없는 값이 되므로 계산 전에 거절합니다. 누락을 0으로 바꾸면 원점의 정확한 측정처럼 들어가 통계를 왜곡합니다. 일부 표본을 제외하기로 한다면 제외 이유와 개수도 남겨야 하며 이번 브라우저 실습은 하나라도 잘못된 쌍이 있으면 전체를 거절합니다.
출력이 0.000000이라고 해서 내부 값이 수학적으로 정확히 0이라는 뜻은 아닙니다. 출력 반올림과 비교 허용오차를 구분합니다. 코드의 아주 작은 값 정리는 음의 영 출력 방지를 위한 표현 처리이고 계산 전에 측정 오차를 없애는 보정이 아닙니다. 센서 오차를 숨기려고 원시 값을 반올림한 후 통계를 계산하지 않습니다. 원본 계산은 유지하고 화면에 내보낼 때만 자릿수를 정합니다.
AssertionError로 기대값과 다른 수치가 보이면 먼저 쌍의 순서, e의 목록, N, 제곱근 위치를 확인합니다. ZeroDivisionError는 빈 목록 검증이 빠졌다는 신호입니다. ValueError가 난다면 숫자와 null 처리부터 살펴봅니다. 이 순서로 확인하면 RMSE를 무작정 기대값에 맞추는 대신 어떤 입력이 계산을 망쳤는지 설명할 수 있습니다. 정상·편향·대칭 잡음·한 표본·빈 목록을 모두 실행합니다.
제출물에는 bias·RMSE·spread와 표본 수를 적고 같은 RMSE라도 원인이 다를 수 있음을 한 문장으로 설명합니다. 참값은 시뮬레이터의 모델 내부 정답이며 실제 장치에서는 별도의 기준 측정과 불확실성이 필요합니다. 가우시안 확률과 공분산으로 불확실성을 표현하는 자세한 내용은 더 읽기로 연결합니다. 여기서는 분포 공식을 복사하는 대신 실제 기록에서 계산 가능한 지표를 완성합니다.
따라하기
편향과 퍼짐을 분리합니다
Python 파일에 저장해 python3 파일명.py로 실행합니다. 같은 RMSE를 서로 다른 bias와 spread로 설명합니다.
import json,sys,math
def metrics(pairs):
if not isinstance(pairs,list) or not pairs: raise ValueError('EMPTY')
errors=[]
for pair in pairs:
if not isinstance(pair,list) or len(pair)!=2 or any(type(v) not in (int,float) or not math.isfinite(v) for v in pair):
raise ValueError('BAD_PAIR')
errors.append(pair[1]-pair[0])
bias=sum(errors)/len(errors)
rmse=math.sqrt(sum(e*e for e in errors)/len(errors))
spread=math.sqrt(sum((e-bias)**2 for e in errors)/len(errors))
return bias,rmse,spread
for pairs in ([[1,1.02],[1,1.02]],[[1,1.02],[1,.98]]):
b,r,s=metrics(pairs)
print(f'bias={b:.6f} rmse={r:.6f} spread={s:.6f}')
실행 결과
bias=0.020000 rmse=0.020000 spread=0.000000 bias=0.000000 rmse=0.020000 spread=0.020000
한 표본의 해석을 확인합니다
N=1에서도 기술 통계는 계산되지만 분포를 확정하지 않습니다.
import json,sys,math
def metrics(pairs):
if not isinstance(pairs,list) or not pairs: raise ValueError('EMPTY')
errors=[]
for pair in pairs:
if not isinstance(pair,list) or len(pair)!=2 or any(type(v) not in (int,float) or not math.isfinite(v) for v in pair):
raise ValueError('BAD_PAIR')
errors.append(pair[1]-pair[0])
bias=sum(errors)/len(errors)
rmse=math.sqrt(sum(e*e for e in errors)/len(errors))
spread=math.sqrt(sum((e-bias)**2 for e in errors)/len(errors))
return bias,rmse,spread
b,r,s=metrics([[2,2.3]])
print(f'bias={b:.6f} rmse={r:.6f} spread={s:.6f}')
print(f'identity={(r*r-b*b-s*s):.6f}')
실행 결과
bias=0.300000 rmse=0.300000 spread=0.000000 identity=0.000000
누락 기록을 오류로 드러냅니다
빈 목록과 null을 참값 0으로 바꾸지 않습니다.
import json,sys,math
def metrics(pairs):
if not isinstance(pairs,list) or not pairs: raise ValueError('EMPTY')
errors=[]
for pair in pairs:
if not isinstance(pair,list) or len(pair)!=2 or any(type(v) not in (int,float) or not math.isfinite(v) for v in pair):
raise ValueError('BAD_PAIR')
errors.append(pair[1]-pair[0])
bias=sum(errors)/len(errors)
rmse=math.sqrt(sum(e*e for e in errors)/len(errors))
spread=math.sqrt(sum((e-bias)**2 for e in errors)/len(errors))
return bias,rmse,spread
for pairs in ([],[[1,None]]):
try: metrics(pairs)
except ValueError as e: print(e)
실행 결과
EMPTY BAD_PAIR
확인 문제
실습
입력 pairs는 [참값,측정값]의 m 단위 쌍 목록입니다. errors.append TODO를 완성하여 bias·RMSE·spread를 그 순서로 소수 여섯 자리 출력합니다. spread는 오차 평균을 뺀 제곱 평균의 제곱근이며 분모는 N입니다. 빈 목록·비정상 쌍은 ERROR입니다. 검증과 출력 코드는 유지합니다.
모범 답안
import json,sys,math
def metrics(pairs):
if not isinstance(pairs,list) or not pairs: raise ValueError('EMPTY')
errors=[]
for pair in pairs:
if not isinstance(pair,list) or len(pair)!=2 or any(type(v) not in (int,float) or not math.isfinite(v) for v in pair):
raise ValueError('BAD_PAIR')
errors.append(pair[1]-pair[0])
bias=sum(errors)/len(errors)
rmse=math.sqrt(sum(e*e for e in errors)/len(errors))
spread=math.sqrt(sum((e-bias)**2 for e in errors)/len(errors))
return bias,rmse,spread
try:
result=metrics(json.load(sys.stdin)['pairs'])
print(' '.join(f'{(0.0 if abs(v)<0.0000005 else v):.6f}' for v in result))
except (ValueError,TypeError,KeyError,OverflowError): print('ERROR')
더 읽기
면접 질문
- 센서 잡음을 추가한 시뮬레이션을 어떻게 평가하는지 설명해 주시면 됩니다.