후보 모델

평균으로 당기기

경험적 베이즈 축소추정

얇은 셀을 노선 평균 쪽으로 당긴다.

확률이 정직한가0.0070얼마나 빗나갔나0.0327확신하고 틀린 벌점0.1024만석을 골라내나0.9613

위 넷은 20일 코퍼스로 낸 옛 성적이고, 초록 칸은 정류장×시간대 기준선 이상인 지표다. AUC .9613은 그 기준선과 같은 값이며 코퍼스 7종 중 가장 낮다. 직접 수집분 재대결에서는 ECE .0328, Brier .0384, log loss .1337, AUC .8527이었고 AUC는 여기서도 7종 중 가장 낮았다.

Q. 만석을 한 번도 못 본 셀은 위험 0%인가?

아니다. 코퍼스 학습분의 노선×정류장×30분 셀은 833개다. 이 중 281개(33.73%)는 표본이 10건 이하다. 719개(86.31%)는 만석을 한 번도 못 봤다. 학습 만석률 자체가 705/12,065=5.84%라서, 표본이 몇 건뿐이면 0회 관측이 흔하다.

위치와 시간은 버리기 아까운 신호다. 아무것도 안 본 기준선 AUC는 .5000, 앞차 한 대만 본 기준선은 .7187, 정류장·30분 셀 기준선은 .9613이었다. 문제는 셀을 쓰느냐가 아니다. 얇은 셀을 얼마나 믿느냐다.

무축소 셀 비율은 만석 0건인 셀에 확률 0을 냈다. 검증 log loss는 .1318이었다. 노선 평균에 해당하는 의사표본 1개를 섞자 .1033으로 줄었다. 이 진단 때문에 축소추정이 본 후보로 올라왔다.

비유하면 학생 한 명만 시험을 본 반의 평균을 그 한 점수로 확정하지 않는 것과 같다. 학생이 적은 반은 학교 평균을 조금 섞는다. 학생이 많아지면 그 반의 점수를 그대로 믿는다.

평균을 두 번 거쳐 셀로 간다
전체 train 12,065건·만석 705건
Jeffreys 보정
전역 만석률 5.847%
의사표본 4개
1650 6.880% / 3330 5.263%
선택 α=.5
정류장×30분 셀
만석확률을 탈 확률로 변환
p_full → 1-p_full

전역 평균으로 노선을 안정시키고, 노선 평균으로 정류장×30분 셀을 안정시킨다.

Q. 어디로, 얼마나 당기나?

Q. 한 칸은 무엇인가?

한 셀은 노선×station_id×로컬 30분대다. 셀 안에서 출발 후 잔여석이 0인 사건 수 m과 전체 사건 수 n을 센다. 도착 시점 0석은 쓰지 않는다. 하차 뒤 자리가 생길 수 있어서다. remainSeatCnt=-1도 결측이라 뺀다.

Q. 당김의 기준은 무엇인가?

먼저 전체 학습분에서 전역 만석률을 구한다. 만석과 비만석에 .5건씩 더하는 Jeffreys 보정을 쓴다. 노선 만석률은 전역 만석률 쪽으로 의사표본 4개만큼 당긴다. 마지막으로 각 셀을 그 노선 만석률 쪽으로 당긴다.

셀 예측식은 하나다.

p_full = (m + α × p_route) / (n + α)

α는 노선 평균이 몇 건의 표본처럼 작용하는지 정한다. 실제 후보는 .5, 1, 2, 4, 8, 16, 32, 64를 날짜 순방향 교차검증으로 겨뤘다. 선택값은 .5였다. 노선 평균을 반 건만 섞는 약한 축소다.

Q. 왜 ‘경험적’ 베이즈인가?

사전평균 p_route를 사람이 정하지 않고 같은 학습 데이터의 노선·전역 빈도에서 구하기 때문이다. beta-binomial 방식으로 노선 평균을 의사표본처럼 보고 셀의 만석/비만석 횟수와 합친다. 사후분포 전체가 아니라 사후평균 하나만 내보낸다.

Q. 처음 보는 셀은 어떻게 하나?

정확한 셀이 없으면 노선×정류장 → 노선×30분대 → 노선 → 전역 순서로 내려간다. 이미 본 셀은 표본이 1건이어도 이 폴백을 쓰지 않는다. 그 셀의 비율과 노선 평균만 섞는다.

출력은 만석 확률 p_full이다. 사용자에게 보이는 탈 확률은 별도 모델이 아니라 1-p_full이다.

얇은 셀만 눈에 띄게 움직인다
무축소축소 후
태영아파트 0/50%0.625%
롯데백화점.범계역 14/2360.87%59.69%

같은 α=.5라도 표본 수가 다르면 당기는 폭이 달라진다.

Q. 실제 숫자는 어떻게 바뀌나?

1650번 태영아파트(station_id 209000243), 18:00~18:30 셀을 보자. 전부 학습 데이터에서 계산한 값이다.

  1. 전체 학습분은 12,065건, 만석 705건이다. Jeffreys 보정을 거친 전역 만석률은 5.847%다.
  2. 학습분의 1650번은 4,331건 중 298건이 만석이다. 전역 평균을 의사표본 4개만큼 섞은 노선 만석률은 6.880%다.
  3. 이 셀은 5건 중 만석 0건이다. 무축소 값은 0%다.
  4. 선택된 α=.5를 넣으면 (0 + .5×.068797) / (5+.5) = .006254다. 만석 확률 0.625%, 탈 확률 99.375%다.

검증 라벨은 이 계산에 넣지 않았다. 뒤 검증에서는 같은 셀 4건 중 1건이 만석이었다. 한 셀의 4건으로 모델 전체를 판정할 수는 없다. 다만 학습의 0/5가 위험 0이라는 뜻은 아니었다.

표본이 두꺼우면 덜 움직인다. 3330번 롯데백화점.범계역 07:00~07:30 셀은 학습 23건 중 14건이 만석이었다. 무축소 60.87%가 축소 뒤 59.69%가 됐다.

Q. 무엇을 쓰고 무엇을 버리나?

쓰는 재료는 네 가지뿐이다.

안 쓰는 재료도 분명하다.

보정층이 뒤 검증에서 위험도를 너무 낮췄다
변형ECE평균 예측관측 만석률
EB raw.00705.4829%5.8068%
EB Platt.01344.4684%5.8068%
EB isotonic.01314.4963%5.8068%

raw보다 Platt·isotonic의 평균 예측이 관측 만석률에서 더 멀어졌다.

Q. 언제 실패하나?

Q. 축소했는데 왜 보정은 나빠졌나?

S1 층화 기준선은 의사표본 1개를 섞었다. 경험적 베이즈가 고른 α=.5는 그보다 약한 축소다. 날짜 순방향 2-fold에서 ECE·Brier·log loss의 합이 .188043으로, α=1.188211보다 아주 조금 낮아 선택됐다. ECE만 고른 결과가 아니다.

ECE·Brier·log loss는 낮을수록 좋고 AUC는 높을수록 좋다.

모델ECEBrierlog lossAUC
층화 기준선.0063.0327.1033.9613
경험적 베이즈 raw.0070.0327.1024.9613

log loss는 .0009 좋아졌지만 ECE는 .0007 나빠졌다. 0% 같은 극단값을 피한 이득과 전체 확률 눈금의 작은 편향이 서로 다른 지표에 잡혔다.

후처리는 더 분명히 실패했다. OOF는 각 날짜를 그보다 앞선 날짜로만 예측해 만든 값이다. 학습 OOF 10,236건·만석 572건에서는 ECE가 raw .0128에서 Platt .0064, isotonic .0016으로 좋아졌다. 하지만 뒤 검증 10,040건·만석 583건에서는 raw .0070이 Platt .0134, isotonic .0131로 나빠졌다. 평균 예측도 raw 5.4829%에서 4.4684%·4.4963%로 내려가 관측 5.8068%를 크게 밑돌았다. 앞 구간에서 배운 눈금이 뒤 날짜로 옮겨가지 않은 것이다. 날짜별 분포 이동, 보정 표본 구성, isotonic의 60개 계단이 각각 얼마나 영향을 줬는지 분리한 실측은 아직 없다.

그 밖의 약점도 남는다.

Q. 프로토타입과 무엇이 다른가?

현재 상태는 직접 수집분 재대결(M2)까지 마친 단계다. 그 재대결은 21쌍 중 16쌍을 못 가려 승자를 정하지 않았고 채택·탈락도 아직 없다.

재료 한눈에

route: 노선별 평균을 만든다.station_id: 정류장 셀을 가른다. 이름은 표시에만 쓴다.로컬 응답 수신시각의 30분대: 서버 queryTime 대신 KST 계산축으로 쓴다.출발 후 만석 라벨: remainSeatCnt==0이면 1, 양수면 0이다. 예측 시점 이후 값이라 학습 정답으로만 쓴다.정원·점유율crowded현재 상류 버스 잔여석·앞차 상태·ETAAPI queryTime

아직 못 써본 것과 그 이유

주의 — 이건 "검증해서 버린 재료"가 아니다

아래 성적은 남의 20일 코퍼스로 낸 것이다. 그 코퍼스는 차량 ID가 가명이라 정원·차종·저상 여부를 붙일 수 없었다(조인 0/77). 이 재료들은 테스트 자체를 못 했다.

우리 직접 수집분에는 원본 차량 ID가 남아 정원이 실제로 붙는다 — 고유차량 55/62대(88.7%). 다만 1회차 재대결에서 정원·차종을 넣어도 뚜렷한 개선은 확인되지 않았다. 표본이 얇아 판정이 안 될 뿐, 쓸모없다는 뜻은 아니다. 관측 최대 잔여석으로 정원을 추정하는 것은 계속 금지다 — 원본 ID를 써도 그 값은 관측할수록 올라가서 정원이 아니다.

직접 수집분으로 다시 잰 성적 1회차

우리 수집기가 직접 쌓은 데이터만 쓰고 코퍼스는 뺐다. 검증분 443행·만석 14건, 정류소×날짜 125군집 부트스트랩 5,000회.

지표직접 수집분 (95% 구간)코퍼스
확률이 정직한가0.0328[0.0135, 0.0556]0.0070
얼마나 빗나갔나0.0384[0.0207, 0.0575]0.0327
확신하고 틀린 벌점0.1337[0.0727, 0.2031]0.1024
만석을 골라내나0.8527[0.7514, 0.9402]0.9613
이 숫자로 순위를 매기지 마라

표본이 얇아 21쌍 중 16쌍이 구분되지 않았다. 검증 날짜가 하루뿐이라 날짜 간 변동도 못 본다. 21쌍 중 16쌍을 못 가리고 validation 날짜가 1개라 전체 순위를 발표하지 않는다

코퍼스 순위가 뒤집힌 쌍은 0개다. 방향은 틀리지 않았고 미세한 차이만 미지로 남았다. ΔAUC 0.05를 가리려면 8 수집일, 날짜 변동까지 보려면 15일이 더 든다.

쓰는 재료 4개 — 출처 필드까지

재료종류어느 API의 어느 필드
노선 (route)직접 수집은 calls.jsonl.route_name, 코퍼스는 route.name을 모델 범주로 읽고 같은 route.routeName 마스터 행을 찾는다. 수집기 설정의 route.route_id와 원응답 routeId는 원문에 남는다.주의 — 현재 S1 구현은 실제로 routeName으로 마스터를 찾는다. msgBody.busLocationList[].routeId를 모델의 route로 복사하지 않는다. 대상 두 노선 밖으로 넓힐 때는 ID 검증이 필요하다.응답 원본msgBody.busLocationList[].routeId; 운영 수집기 route.name, route.route_id; S1 E4 calls.jsonl.route_name; 코퍼스 route.name; route.routeName, route.routeId; S1 routeGBIS 버스위치정보 getBusLocationListv2 + 우리 수집기 노선 메타데이터 + 제한 코퍼스 + GBIS route 마스터
목표 정류장 ID (station_id)차량별 정규화 위치가 전진한 끝점의 station_seq를 만들고, 같은 노선의 staOrder와 맞춰 stationId를 붙인다.주의 — 응답의 msgBody.busLocationList[].stationId를 목표 통과 정류장 ID로 복사하지 않는다. 그 값은 응답 시점 차량 위치의 정류장이다.파생msgBody.busLocationList[].vehId, msgBody.busLocationList[].stationSeq, msgBody.busLocationList[].stateCd; routestation.routeName, routestation.staOrder, routestation.stationId; S1 station_idGBIS 버스위치정보 getBusLocationListv2 + GBIS routestation 마스터 + S1 통과 사건 빌더
KST 30분대 (query_half_hour)응답 수신시각을 KST로 바꾸고 hour*2 + 1[minute>=30]으로 0~47을 만든다.주의 — msgHeader.queryTime과 HTTP Date는 시간 계산에 쓰지 않는다. 코퍼스 collectedAt은 본문 파싱 뒤 시각이다.파생timing.response_received_at.kst, timing.response_received_at.utc; S1 E4 response_received_kst, response_received_utc; 코퍼스 collectedAt; S1 query_half_hour우리 수집기 응답 수신시각; 제한 코퍼스는 수집기가 붙인 collectedAt
출발 후 만석 라벨 (label_full)정류장 s 통과 뒤 같은 차량에서 (stateCd,stationSeq)=(2,s),(0,s),(1,s+1) 중 처음 잡힌 유효 좌석값을 찾는다. 0이면 1, 양수면 0이다.주의 — remainSeatCnt=-1은 결측이다. (stateCd,stationSeq)=(1,s)는 도착 상태라 라벨이 아니다. 예측 시점 뒤에 생기므로 학습 정답으로만 쓴다.파생msgBody.busLocationList[].vehId, msgBody.busLocationList[].stationSeq, msgBody.busLocationList[].stateCd, msgBody.busLocationList[].remainSeatCnt; timing.response_received_at.kst, timing.response_received_at.utc; S1 passage_events.label_full, label_remain_seatsGBIS 버스위치정보 getBusLocationListv2 + 우리 수집기 응답 수신시각 + S1 통과 사건 빌더

지금은 안 쓰는 재료 5개

언제 실패하나

기존 프로토타입과 다른 점