후보 모델

본사와 지점

계층 로지스틱 (부분 풀링)

정류장·시간·셀 효과를 나눠 배우고, 얇은 셀을 0%로 단정하지 않는다.

확률이 정직한가0.0090얼마나 빗나갔나0.0341확신하고 틀린 벌점0.1079만석을 골라내나0.9628

1. Q. 왜 셀 비율을 그대로 쓰지 않나?

정류장×30분대 셀은 833개다. 이 중 281개(33.73%)는 학습 표본이 10건 이하다. 719개(86.31%)는 만석을 한 번도 못 봤다. 0회라는 기록과 만석 위험 0%는 같은 말이 아니다.

위치와 시간 자체는 강한 신호였다. 정류장·시간만 본 기준선의 검증 AUC는 .9613이었다. 하지만 셀 비율을 그대로 쓰면 학습에서 만석 0건인 셀에 확률 0을 줬다. 그 결과 log loss가 .1318이었다. 의사표본 1개만 섞어도 .1033으로 내려갔다. 셀은 살리되 얇은 셀의 확신은 눌러야 했다.

여기서 계층 로지스틱이 나왔다. 정류장 효과와 시간 효과를 따로 배우고, 둘이 만난 셀 효과도 남긴다. 표본이 적은 효과는 0 쪽으로 당기고, 기록이 쌓인 효과는 더 멀리 움직인다. 하루 매출 한 건만 나온 프랜차이즈 지점을 그 한 건으로 단정하지 않고, 본사 평균과 다른 날 기록도 함께 보는 것과 같다.

한 셀을 네 효과로 푼다
노선·정류장·30분대
노선 분리
노선 고정효과
0 중심 Gaussian 규제
부분 풀링
노선×정류장 효과
노선×30분대 효과
정확한 셀 효과 · 4배 규제
더함
로짓 합
σ, 그 뒤 1-p
만석확률 → 탈 확률

노선은 고정효과다. 정류장·30분대·정확한 셀은 0 중심 Gaussian 규제를 받는다.

2. Q. 한 셀을 어떻게 나누나?

Q. 무엇을 더하나?

모델은 만석확률을 먼저 낸다. 사용자에게 보이는 탈 확률은 그 여집합이다.

p_full = σ(절편 + 노선 + 노선×정류장 + 노선×30분대 + 정확한 셀)

30분대는 KST 응답 수신 시각으로 만든다. σ는 더한 점수를 0과 1 사이 확률로 바꾸는 로지스틱 함수다. 마지막에 p_board = 1 - p_full로 뒤집는다.

Q. “공통 분포”는 실제 코드에서 무엇인가?

노선은 규제하지 않는 고정효과다. 노선×정류장 139개, 노선×30분대 12개, 정확한 셀 833개는 0을 중심으로 한 Gaussian 규제를 받는다. 랜덤효과 계수는 모두 984개다.

완전한 베이즈 추론은 아니다. 계수의 사후분포를 뽑지 않고, 로지스틱 손실과 Gaussian 벌점을 함께 최소화한 MAP 추정이다. 벌점 후보 .5, 1, 2, 4, 8, 16, 32를 학습 구간 안의 날짜 순방향 2-fold로 겨뤘고 .5가 골라졌다.

Q. 셀 효과는 왜 더 세게 누르나?

정류장 효과와 시간 효과의 벌점은 .5다. 정확한 셀은 그 4배인 2.0이다. 셀 하나의 우연한 0회·1회보다 여러 시간에 반복된 정류장 성향과 여러 정류장에 반복된 시간 성향을 먼저 믿겠다는 뜻이다.

처음 보는 정류장×시간 조합에는 정확한 셀 효과가 0으로 들어간다. 정류장과 시간은 학습에서 봤다면 두 효과는 그대로 남는다. 정류장 자체도 처음이면 그 효과까지 0이 된다.

안양시청 18:00 셀의 점수
절편-4.0796
3330-0.9832
안양시청+3.2674
18:00+0.1977
정확한 셀-0.3685
-1.9661
raw p_full+0.1228
isotonic p_full+0.1181

학습 6건·만석 0회인 셀도 다른 시간의 정류장 효과를 받아 raw 만석확률 .1228이 됐다.

3. Q. 실제 숫자는 어떻게 합쳐지나?

코퍼스 학습분 12,065건·만석 705건으로 적합한 값이다. 3330번 안양시청 정류장(ID 209000108), 18:00~18:30 셀을 본다. 이 셀의 학습 표본은 6건이고 만석은 0건이었다. 같은 정류장의 여섯 30분대를 합치면 141건 중 만석 33건이었다.

더하는 항적합값
절편-4.0796
3330 노선-0.9832
3330×안양시청+3.2674
3330×18:00~18:30+0.1977
안양시청×18:00~18:30 셀-0.3685
-1.9661

σ(-1.9661) = .1228이다. 셀 비율만 보면 0/6 = 0%지만, 부분 풀링한 보정 전 만석확률은 12.28%다. 학습 안에서 날짜순으로 떼어 낸 예측값에 isotonic(단조) 보정을 맞추면 .1181이 된다. 최종 탈 확률은 1 - .1181 = .8819, 88.19%다. 시간순 검증에서도 이 셀은 8건 중 만석 0건이었다. 8건만으로 0%와 11.81% 중 어느 쪽이 맞는지는 가릴 수 없다.

Q. 그래서 기준선을 이겼나?

아니다. 아래는 코퍼스 검증 10,040건·만석 583건에서, 각 후보군의 대표 변형을 비교한 값이다. 계층 로지스틱은 isotonic 변형이다.

지표정류장·시간 기준선계층 로지스틱좋은 방향
ECE.0063.0090낮음
Brier.0327.0341낮음
log loss.1033.1079낮음
AUC.9613.9628높음
비대칭 log loss.1670.1815낮음

AUC만 .0015 높았다. ECE·Brier·log loss·비대칭 log loss는 모두 나빠졌다. 순서는 조금 더 잘 세웠지만 확률값은 기준선보다 못 맞췄다.

4. Q. 무엇을 넣고, 무엇을 뺐나?

재료쓰임이유
노선고정효과두 노선의 전체 기저 차이를 분리한다.
station_id노선×정류장 랜덤절편위치 단독 AUC가 .9336이었다.
query_half_hour노선×30분대 랜덤절편시간만 본 AUC는 .6375였다. 위치와 따로 둔다.
노선×정류장×30분대강하게 규제한 셀 효과정류장과 시간이 만날 때만 생기는 차이를 남긴다.
출발 후 remainSeatCnt정답 생성에만 사용0이면 label_full=1, 양수면 0이다. 예측 입력은 아니다.

안 쓰는 재료도 분명하다.

아직 못 써본 재료왜 뺐나
정원·점유율코퍼스 가명 차량과 외부 정원의 조인이 0/77대다. 폐기된 옛 데이터 231,048행도 capacity_exact가 전부 0이었다. 관측 최대 잔여석은 정원이 아니라 하한이다. 라벨에도 정원이 필요 없다.
crowded공식 제공 대상은 노선유형 13·15·23인데 대상 노선은 유형 11이다. 직접 수집분에서 같은 잔여석에 다른 코드가 붙은 값이 26개라 잔여석만의 함수는 아니다. 다만 같은 (vehId, remainSeatCnt)에서는 충돌이 0셀이고 산식도 미확정이라 아직 넣지 않았다.
앞차 상태·실시간 잔여석·노선 전체 요약금지 재료는 아니다. 이 후보는 정류장·시간 계층만 떼어 검정하려고 넣지 않았다.
queryTime서버 시계가 연속 호출 38쌍 중 7쌍에서 되감겼다. 시간대는 로컬 응답 수신 시각으로만 만든다.
AUC만 조금 올랐다
지표정류장·시간 기준선계층 로지스틱
ECE ↓.0063.0090
Brier ↓.0327.0341
log loss ↓.1033.1079
AUC ↑.9613.9628
비대칭 log loss ↓.1670.1815

코퍼스 검증 10,040건·만석 583건. 계층 로지스틱은 isotonic 대표 변형이다.

5. Q. 어떤 그림으로 보면 되나?

그림 1은 한 셀을 노선·정류장·시간·정확한 셀 효과로 나누는 흐름이다. 그림 2는 안양시청 18:00 셀의 실제 적합값을 더하는 눈금이다. 그림 3은 AUC만 소폭 높고 다른 네 지표는 나빠진 비교표다.

6. Q. 언제 깨지고, 프로토타입과 무엇이 다른가?

Q. 언제 실패하나?

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

프로토타입계층 로지스틱
화면의 탑승 확률은 정류장×평일/주말×30분대의 마지막 스냅샷에서 1-zeroCount/samples로 계산했다.출발 후 0석 라벨로 만석확률을 직접 학습하고 1-p_full을 탈 확률로 낸다.
셀 비율을 그대로 썼다. 얇은 셀을 다른 정류장·시간과 묶지 않았다.정류장·시간·정확한 셀 효과를 나눠 부분 풀링한다.
차량 좌석 예보는 현재 잔여석과 수요 μ·σ를 0~80석 분포로 전파한 별도 계산이었다.좌석 수를 예보하지 않는다. 노선·정류장·30분대만으로 확률 하나를 낸다.
좌석 예보, +10 여유폭, 만석 연속으로 여유/빠듯/어려움을 정했다.+10, 만석 연속, 대기열, 이동 추천을 쓰지 않는다.
확률 보정층이 없었다.학습 안에서 날짜순으로 떼어 낸 예측값으로 Platt와 isotonic을 따로 검정했다. 이 모델에서는 isotonic도 기준선보다 나빴다.

재료 한눈에

노선 — 규제하지 않는 고정효과station_id — 노선×정류장 랜덤절편query_half_hour — KST 응답 수신 시각의 노선×30분대 랜덤절편노선×정류장×30분대 — 4배 강하게 규제한 정확한 셀 효과출발 후 remainSeatCnt — label_full 정답 생성에만 사용정원·점유율crowded앞차 상태·실시간 잔여석·노선 전체 요약queryTime

아직 못 써본 것과 그 이유

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

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

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

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

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

지표직접 수집분 (95% 구간)코퍼스
확률이 정직한가0.0321[0.0230, 0.0449]0.0090
얼마나 빗나갔나0.0272[0.0138, 0.0422]0.0341
확신하고 틀린 벌점0.1103[0.0706, 0.1533]0.1079
만석을 골라내나0.9230[0.8509, 0.9706]0.9628
이 숫자로 순위를 매기지 마라

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

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

주의 — 이번 실행에서는 보정층이 작동하지 않았다. 검증 날짜가 하루뿐이라 적격 보정 fold가 없었다. 이름에 isotonic이 붙어 있어도 위 숫자는 보정 전 예측이다. 보정 효과는 반증된 것이 아니라 아직 재지 못한 것이다.

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

재료종류어느 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
노선×정류장 효과 (route_station)route + '|' + station_id 문자열 범주를 만든다.주의 — 원 API에 같은 이름의 필드는 없다. 학습에서 못 본 조합은 새 범주로 처리하지 않고 구현의 기본 효과로 돌아간다.파생S1 route, station_id → 모델 내부 route_station계층 로지스틱 내부 조합
노선×30분대 효과 (route_time)route + '|' + query_half_hour 문자열 범주를 만든다.주의 — 원 API 필드가 아니다.파생S1 route, query_half_hour → 모델 내부 route_time계층 로지스틱 내부 조합
노선×정류장×30분 셀 효과 (cell)route + '|' + station_id + '|' + query_half_hour 문자열 범주를 만든다.주의 — 얇은 셀을 그대로 독립 비율로 쓰지 않고 상위 효과와 함께 축소한다.파생S1 route, station_id, query_half_hour → 모델 내부 cell계층 로지스틱 내부 조합
출발 후 만석 라벨 (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 통과 사건 빌더

지금은 안 쓰는 재료 4개

언제 실패하나

기존 프로토타입과 다른 점