Devin.KR

같은 조건의 결과 비교

90분 안팎

학습 목표

조건별 도달률·충돌·정지 사유·RMSE를 집계합니다.

개념

한 번의 성공보다 비교 가능한 분모가 필요합니다

로봇 제어를 수정한 뒤 한 번 도착했다고 성능이 개선되었다고 말하면 우연과 변경 효과를 구분하기 어렵습니다. 이번 레슨은 조건과 반복을 고정하고 성공·실패를 함께 집계합니다. 미션에서는 clean·noise·delay·missing·blocked 다섯 조건을 seed 0부터 4까지 실행합니다. 같은 목표와 제어 설정을 유지하고 바꾼 항목을 실험 표에 드러내 총 25회 결과를 설명합니다.

clean은 이상적인 바퀴 이동량, noise는 바퀴별 tick 이동량에 표준편차 0.02m 잡음을 추가한 조건입니다. delay는 같은 센서 값을 0.15초 늦게 전달합니다. missing은 시각 0.50초에 누락 입력을 넣습니다. blocked는 한 셀만 열린 통로가 로봇 크기 팽창 후 막히는 지도입니다. 센서 누락과 지도 변화는 한 연속 변수의 단계가 아니므로 각각 별도 조건 이름으로 보고합니다.

결과 한 행의 의미를 고정합니다

한 행은 condition과 seed로 식별합니다. reached는 평가 기준을 만족한 도달, collisions는 참값 궤적의 충돌 구간 수, reason은 종료 이유, samples는 평가한 주행 표본 수입니다. 같은 조건과 seed를 두 번 넣으면 분모가 부풀어 오르므로 집계기는 중복을 거절합니다. 재생 a와 b는 재현성 검사 쌍이며 독립된 두 실험으로 합산하지 않습니다.

도달률은 도달한 실행 수를 전체 실행 수로 나눕니다. 누락·막힌 통로·시간 초과도 전체 실행에 포함합니다. 성공한 실행만 골라 분모로 삼으면 모든 조건에서 도달률이 1이 되는 편향이 생깁니다. 액션이 SUCCEEDED여도 참값이 목표에서 멀거나 충돌했다면 reached는 false입니다. 이것이 제어기가 보는 추정과 평가기가 보는 참값을 분리한 효과입니다.

실패 사유는 원인을 조사할 수 있게 횟수와 함께 남깁니다. SENSOR_MISSING은 입력 누락, SENSOR_STALE은 입력 나이 초과, PATH_NOT_FOUND는 유효 경로 부재입니다. TRUTH_NOT_REACHED는 제어기는 도착했다고 판단했지만 참값은 도착 허용 거리 밖인 경우입니다. 사유가 다른 실패를 모두 timeout이라고 바꾸면 개선할 책임과 검사 범위가 흐려집니다.

RMSE를 섞을 때는 제곱 오차로 돌아갑니다

이 레슨의 sse_m2는 동시각 추정 x·y와 참값 x·y의 거리 제곱을 모든 표본에 대해 합한 값입니다. samples로 나눈 뒤 제곱근을 취하면 위치 RMSE이며 단위는 m입니다. 서로 다른 길이의 실행을 합칠 때는 각 실행의 sse_m2를 더하고 전체 samples로 나눕니다. 실행마다 먼저 계산한 RMSE의 단순 평균과 일반적으로 같지 않습니다.

예를 들어 한 표본의 RMSE가 1m이고 세 표본의 RMSE가 3m라면 합친 제곱합은 1 곱하기 1² 더하기 3 곱하기 3²입니다. 전체 표본 수 4로 나눈 값 7의 제곱근이 조건 RMSE입니다. 단순 평균 2m는 표본 수 차이를 반영하지 않습니다. 보고서에 표본 가중 집계인지 실행별 평균인지 정의를 적어 리뷰어가 수치를 재계산할 수 있게 합니다.

시작하자마자 경로가 없어 종료되면 주행 표본 수가 0입니다. 제곱합이 0이라고 RMSE도 0으로 보고하지 않습니다. 분모가 없으므로 null이며 추정이 완벽했다는 근거가 없습니다. 이와 달리 주행 표본이 존재하고 오차가 작으면 작은 수 또는 반올림된 0을 낼 수 있습니다. 샘플 없음과 측정된 작은 오차를 구분하는 것이 경계 테스트의 목적입니다.

충돌과 안전 정지를 함께 읽습니다

collision 수는 평가한 구간에서 swept_clear가 거짓인 횟수입니다. 장애물 개수나 사고 사건 수와 같은 정의가 아닙니다. 한 번의 접촉이 여러 구간에 걸리면 여러 번 집계될 수 있습니다. 제어기가 예측한 COLLISION_RISK와 실제 참값 구간의 충돌은 따로 해석합니다. 충돌 위험을 미리 막아 정지한 실행은 도달 실패여도 필요한 방어 행동일 수 있습니다.

missing과 blocked에서 도달률이 낮아지는 것은 이 미션의 기대된 동작입니다. 센서 없이 계속 움직여 높은 도달률을 얻는 구현은 통과 기준을 만족하지 않습니다. 개선의 방향은 모든 실패를 성공으로 바꾸는 것이 아니라 안전한 실패는 유지하면서 정상 조건의 도달을 지키는 것입니다. 그래서 reach_rate와 collisions와 failures를 나란히 보고 하나의 점수로 조급하게 압축하지 않습니다.

지연 조건의 RMSE가 clean과 같을 수 있습니다. 여기서 위치 추정은 바퀴 이동량을 적분하며 range는 고정된 안전 입력입니다. range를 위치 보정에 사용하지 않으므로 허용 범위 안의 수신 지연이 위치 RMSE를 바꾸지 않을 수 있습니다. 이 결과를 보고 지연이 모든 로봇에서 무해하다고 일반화하지 않습니다. 실제 센서 융합이나 장애물 감지의 지연 효과는 다른 모델과 시험이 필요합니다.

실험 비교의 한계를 보고합니다

seed 0부터 4까지는 회귀 검사에서 추적 가능한 다섯 실행입니다. 이 표본으로 제품의 장기 실패율을 정밀하게 추정했다고 말하지 않습니다. noise 조건에서는 각 seed의 종료 시각과 추정 오차가 다를 수 있어 samples도 다릅니다. 조건 RMSE에는 서로 다른 종료 구간이 들어간다는 점을 적고 필요하다면 공통 시간 구간의 지표를 추가하는 후속 계획을 세웁니다.

브라우저 starter는 도달률과 RMSE 식을 0으로 둔 상태입니다. 입력 검증과 그룹 생성은 제공하며 두 식을 완성합니다. rows가 비면 []를 반환하지만 중복 조건·seed, 음수 표본 수, 표본 없는 양수 제곱합, 충돌한 도달 성공은 ERROR입니다. 입력 정합성을 먼저 확인해야 잘못된 기록을 그럴듯한 평균으로 감추지 않습니다.

기대값과 결과가 다르면 조건별 행 수, reached 목록, 총 samples, 총 sse_m2를 중간 변수로 살펴봅니다. 0과 0.0의 차이는 JSON 숫자의 표현일 수 있으나 null과 0의 차이는 의미의 차이입니다. 과제 출력은 소수 여섯 자리로 반올림한 숫자를 JSON으로 내보내며 검증기는 정해진 형식을 비교합니다. 원시 오차를 먼저 반올림해 큰 오차를 지우지 않습니다.

제출물에는 조건별 분모, 도달률, 충돌 수, 실패 사유별 횟수, RMSE의 정의와 표본 수를 남깁니다. 집계기만 작성하고 원본 행을 지우면 재계산과 오류 조사가 어려워집니다. 미션의 matrix.json은 원본 결과 행을, summary.json은 조건 요약을 보존합니다. CSV 파일 작성과 보고서 출력의 자세한 형식은 더 읽기에 맡기고 여기서는 비교 기준을 코드로 고정합니다.

따라하기

다른 길이의 실행을 합칩니다

각 실행의 제곱합과 표본 수를 합칩니다. 단순 RMSE 평균과 다른 결과를 확인합니다.

import math
sse=1*1**2+3*3**2
samples=1+3
print(f'pooled_rmse_m={math.sqrt(sse/samples):.6f}')
print(f'mean_rmse_m={(1+3)/2:.6f}')

실행 결과

pooled_rmse_m=2.645751
mean_rmse_m=2.000000

도달과 시간 초과를 같은 분모로 집계합니다

두 행 중 한 행이 실패합니다. 실패 사유와 도달률을 함께 보존합니다.

import json,sys,math

def aggregate(rows):
    if not isinstance(rows,list): raise ValueError('ROWS')
    groups={};seen=set()
    for r in rows:
        c=r['condition'];seed=r['seed'];n=r['samples'];sse=r['sse_m2'];hits=r['collisions']
        if not isinstance(c,str) or not c or type(seed) is not int or (c,seed) in seen: raise ValueError('ID')
        seen.add((c,seed))
        if type(n) is not int or n<0 or type(hits) is not int or hits<0: raise ValueError('COUNT')
        if type(sse) not in (int,float) or not math.isfinite(sse) or sse<0 or (n==0 and sse!=0): raise ValueError('SSE')
        if type(r['reached']) is not bool or not isinstance(r['reason'],str) or not r['reason']: raise ValueError('RESULT')
        if r['reached'] and (hits!=0 or r['reason']!='ARRIVED'): raise ValueError('CONFLICT')
        g=groups.setdefault(c,dict(runs=0,reached=0,collisions=0,samples=0,sse_m2=0.,failures={}))
        g['runs']+=1;g['reached']+=int(r['reached']);g['collisions']+=hits;g['samples']+=n;g['sse_m2']+=sse
        if not r['reached']: g['failures'][r['reason']]=g['failures'].get(r['reason'],0)+1
    result=[]
    for c,g in sorted(groups.items()):
        rate=g['reached']/g['runs']
        rmse=math.sqrt(g['sse_m2']/g['samples']) if g['samples'] else None
        result.append(dict(condition=c,runs=g['runs'],reach_rate=round(rate,6),collisions=g['collisions'],
                           rmse_m=round(rmse,6) if rmse is not None else None,failures=g['failures']))
    return result

print(json.dumps(aggregate([{'condition': 'clean', 'seed': 0, 'reached': True, 'reason': 'ARRIVED', 'collisions': 0, 'samples': 1, 'sse_m2': 1}, {'condition': 'clean', 'seed': 1, 'reached': False, 'reason': 'TIMEOUT', 'collisions': 0, 'samples': 3, 'sse_m2': 27}]),sort_keys=True))

실행 결과

[{"collisions": 0, "condition": "clean", "failures": {"TIMEOUT": 1}, "reach_rate": 0.5, "rmse_m": 2.645751, "runs": 2}]

표본 없는 실패를 null로 보고합니다

경로가 없으면 오차가 0이라는 평가를 하지 않습니다. 빈 입력 목록의 결과도 확인합니다.

import json,sys,math

def aggregate(rows):
    if not isinstance(rows,list): raise ValueError('ROWS')
    groups={};seen=set()
    for r in rows:
        c=r['condition'];seed=r['seed'];n=r['samples'];sse=r['sse_m2'];hits=r['collisions']
        if not isinstance(c,str) or not c or type(seed) is not int or (c,seed) in seen: raise ValueError('ID')
        seen.add((c,seed))
        if type(n) is not int or n<0 or type(hits) is not int or hits<0: raise ValueError('COUNT')
        if type(sse) not in (int,float) or not math.isfinite(sse) or sse<0 or (n==0 and sse!=0): raise ValueError('SSE')
        if type(r['reached']) is not bool or not isinstance(r['reason'],str) or not r['reason']: raise ValueError('RESULT')
        if r['reached'] and (hits!=0 or r['reason']!='ARRIVED'): raise ValueError('CONFLICT')
        g=groups.setdefault(c,dict(runs=0,reached=0,collisions=0,samples=0,sse_m2=0.,failures={}))
        g['runs']+=1;g['reached']+=int(r['reached']);g['collisions']+=hits;g['samples']+=n;g['sse_m2']+=sse
        if not r['reached']: g['failures'][r['reason']]=g['failures'].get(r['reason'],0)+1
    result=[]
    for c,g in sorted(groups.items()):
        rate=g['reached']/g['runs']
        rmse=math.sqrt(g['sse_m2']/g['samples']) if g['samples'] else None
        result.append(dict(condition=c,runs=g['runs'],reach_rate=round(rate,6),collisions=g['collisions'],
                           rmse_m=round(rmse,6) if rmse is not None else None,failures=g['failures']))
    return result

rows=[{'condition': 'blocked', 'seed': 0, 'reached': False, 'reason': 'PATH_NOT_FOUND', 'collisions': 0, 'samples': 0, 'sse_m2': 0}]
print(json.dumps(aggregate(rows),sort_keys=True))
print(json.dumps(aggregate([])))

실행 결과

[{"collisions": 0, "condition": "blocked", "failures": {"PATH_NOT_FOUND": 1}, "reach_rate": 0.0, "rmse_m": null, "runs": 1}]
[]

확인 문제

실습

rows의 condition·seed는 실행 고유 키, reached는 bool, reason은 비어 있지 않은 종료 사유, collisions·samples는 비음수 정수, sse_m2는 비음수 유한 제곱 오차 합입니다. condition 사전순으로 runs·reach_rate·collisions·rmse_m·failures를 JSON 배열로 반환합니다. reached는 전체 실행 분모를 쓰고 RMSE는 합친 제곱합/전체 표본 수의 제곱근입니다. 표본 0이면 null, 빈 rows면 []입니다. 숫자는 소수 여섯 자리 반올림입니다. 실패 사유는 reached=false인 행만 셉니다. 중복 키·bool seed·표본 0의 양수 제곱합·충돌 또는 ARRIVED 외 사유를 가진 성공은 ERROR입니다. starter의 rate와 rmse 두 TODO를 완성합니다.

모범 답안
import json,sys,math

def aggregate(rows):
    if not isinstance(rows,list): raise ValueError('ROWS')
    groups={};seen=set()
    for r in rows:
        c=r['condition'];seed=r['seed'];n=r['samples'];sse=r['sse_m2'];hits=r['collisions']
        if not isinstance(c,str) or not c or type(seed) is not int or (c,seed) in seen: raise ValueError('ID')
        seen.add((c,seed))
        if type(n) is not int or n<0 or type(hits) is not int or hits<0: raise ValueError('COUNT')
        if type(sse) not in (int,float) or not math.isfinite(sse) or sse<0 or (n==0 and sse!=0): raise ValueError('SSE')
        if type(r['reached']) is not bool or not isinstance(r['reason'],str) or not r['reason']: raise ValueError('RESULT')
        if r['reached'] and (hits!=0 or r['reason']!='ARRIVED'): raise ValueError('CONFLICT')
        g=groups.setdefault(c,dict(runs=0,reached=0,collisions=0,samples=0,sse_m2=0.,failures={}))
        g['runs']+=1;g['reached']+=int(r['reached']);g['collisions']+=hits;g['samples']+=n;g['sse_m2']+=sse
        if not r['reached']: g['failures'][r['reason']]=g['failures'].get(r['reason'],0)+1
    result=[]
    for c,g in sorted(groups.items()):
        rate=g['reached']/g['runs']
        rmse=math.sqrt(g['sse_m2']/g['samples']) if g['samples'] else None
        result.append(dict(condition=c,runs=g['runs'],reach_rate=round(rate,6),collisions=g['collisions'],
                           rmse_m=round(rmse,6) if rmse is not None else None,failures=g['failures']))
    return result
try: print(json.dumps(aggregate(json.load(sys.stdin)['rows']),sort_keys=True))
except (ValueError,KeyError,TypeError,OverflowError): print('ERROR')

더 읽기

면접 질문

  • 센서 잡음을 추가한 시뮬레이션을 어떻게 평가하는지 설명해 주시면 됩니다.