후보 모델
이웃에게 빌리기
GAM / 스플라인
정류소 순번과 KST 시각을 노선별 시공간 면으로 펴고, 출발 후 만석확률을 바로 낸다.
위 넷은 20일 코퍼스로 낸 옛 성적이고, 초록 칸은 정류장×시간대 기준선을 넘은 지표다. 직접 수집분 재대결에서는 ECE .0715, Brier .0531, log loss .1609, AUC .8873이었다. ECE는 7종 중 가장 나빴고 AUC 점추정 순위는 5위에서 6위로 내려갔다.
1. 왜 이 방법인가
Q. 정류장을 그냥 낱개 칸으로 외우면 안 되나?
코퍼스 보조 학습자료에는 노선×정류장×30분 셀이 833개 있다. 이 중 281개(33.73%)는 표본이 10건 이하다. 719개(86.31%)는 만석을 한 번도 못 봤다. 칸의 비율을 그대로 쓰면 세 번 보고 만석이 없던 셀도 위험 0으로 굳는다.
그런데 무축소 셀 조회에서 검증 정류장 순번을 한 칸 앞이나 뒤로 옮겨도 AUC가 .9100과 .9113으로 남았다. 정류장 ID 하나에만 붙은 신호라면 이렇게 버티기 어렵다. 위험이 노선 위에서 이어진다는 근거다.
GAM은 이 틈을 쓴다. 정류장을 이름이나 ID가 아니라 노선 위 순번으로 놓는다. 얇은 셀은 바로 옆 정류장과 가까운 시각의 기록을 나눠 쓴다.
| 순번 -1 | AUC .9100 |
|---|---|
| 순번 +1 | AUC .9113 |
무축소 노선×정류장×30분 조회에서 검증 정류장 순번만 옮긴 공격이다.
2. 어떻게 작동하나
Q. 순번 하나가 어떻게 곡선이 되나?
3330은 순번 1~85, 1650은 1~89다. 구현은 1~89 축에 3차 B-스플라인 매듭 8개를 놓는다. 실제로 생기는 공간 기저 열은 9개다. 이웃 순번은 겹치는 기저 열을 비슷한 비율로 켠다. 그래서 순번 60의 기록은 61에도 조금 닿고, 멀리 떨어진 20에는 거의 닿지 않는다.
얇은 고무판을 몇 군데 눌러 모양을 잡는 것과 같다. 관측 한 점이 주변까지 움직이되, 노선 전체가 같은 높이가 되지는 않는다.
Q. 시각도 같은 방식으로 잇나?
로컬 응답 수신시각을 하루의 분으로 바꾼다. 0~1,440분 축에는 3차 주기 스플라인 매듭 6개를 놓고, 실제 시간 기저 4개를 만든다. 주기형이라 자정 양쪽이 끊기지 않는다. 요일은 사인·코사인 2열로 넣는다. 계획 배차간격도 1열 넣지만, 실제 배차로 읽지는 않는다.
Q. 공간 곡선과 시간 곡선을 왜 곱하나?
공간 9열과 시간 4열을 모두 곱해 36열을 만든다. 이것이 텐서 면이다. 단순히 정류장 효과 + 시간 효과만 더하면 모든 정류장이 같은 시간에 함께 오르내린다. 곱셈 항을 두면 7시의 순번 61만 높고, 같은 61도 저녁에는 낮은 모양을 배운다.
이 면은 노선별로 따로 만든다. 한 노선당 노선 표시 1 + 공간 9 + 시간 4 + 텐서 36 = 50열이다. 두 노선 100열에 요일 2열과 계획 배차 1열을 더해 실제 입력은 103열이다.
Q. 곡선이 어떻게 확률이 되나?
전용 GAM 패키지를 쓰지 않는다. 103개 기저 열을 표준화한 뒤 L2 규제를 건 로지스틱 회귀에 넣는다. 코퍼스 학습자료 내부의 날짜 순방향 교차검증이 공간 매듭 8개, 시간 매듭 6개, C=.3을 골랐다.
작은 식은 이것뿐이다.
z는 103개 열의 가중합이다. C=.3 규제가 계수를 눌러 얇은 구간에서 곡선이 심하게 출렁이는 일을 막는다.
실제 구현은 공간·시간 기저를 만든 뒤 노선별 텐서 면과 규제 로지스틱을 거친다.
3. 숫자로 따라가기
Q. 실제 한 행은 어떻게 계산됐나?
2026-08-03 월요일 07:06:05 KST의 코퍼스 검증행을 본다.
| 단계 | 실제 값 |
|---|---|
| 질의 | 3330 · 농수산물시장 · 순번 61 |
| 시간 입력 | 하루 426분 · 요일 코드 0(월요일) · 계획 배차 5분 |
| 스플라인·텐서·규제 회귀 뒤 점수 | z=.9572 |
| 후처리 전(raw) 만석확률 | p_full=.7226 |
| 사용자용 탈 확률 | 1-.7226=.2774 |
| 확인된 정답 | 07:07:08, 순번 61 출발(stateCd=2) 뒤 0석 |
이 행에서 모델은 탈 확률을 27.74%로 냈다. 63초 뒤 출발 관측은 0석이라 라벨은 만석 1이다. 다만 이 라벨은 좌석이 모두 소진됐다는 뜻이다. 줄에 있던 개인이 실제로 못 탔다는 현장 기록은 아직 없다.
Q. 전체 검증에서도 나아졌나?
코퍼스 보조 검증자료 10,040건·만석 583건에서 raw GAM의 ECE는 .0038, AUC는 .9647이었다. 정류소×시간 기준선은 .0063, .9613이었다. 반면 Brier는 .0328 대 .0327, log loss는 .1034 대 .1033으로 사실상 제자리였다. 순위는 조금 나아졌지만 전체 확률 오차를 줄였다고 보기는 어렵다.
Platt 보정 뒤 ECE는 .0190, isotonic 뒤에는 .0082였다. raw의 .0038보다 나빠서 이 모델은 raw를 대표값으로 쓴다.
4. 쓰는 재료와 그림
Q. 실제 모델에 들어가는 값은 무엇인가?
| 재료 | 쓰는 이유 |
|---|---|
| 노선 | 3330과 1650의 시공간 면을 분리한다. |
| 정류장 순번 | 정류장을 노선 위 연속 좌표로 놓는다. |
| 로컬 하루 분 | KST 응답 수신시각을 연속 시간축으로 쓴다. |
| 로컬 요일 | 사인·코사인으로 주간 주기를 넣는다. |
| 계획 배차간격 | 정적 운행계획의 차이를 보조한다. 실제 배차는 아니다. |
| 출발 후 만석 라벨 | 학습할 때만 쓴다. 출발 후 0석은 1, 양수는 0이다. |
Q. 무엇을 일부러 빼나?
| 아직 못 써본 재료 | 이유 |
|---|---|
| 정류장 ID·이름 | 범주를 외우지 않고 순번의 연속성을 시험하는 모델이다. |
| 정원·점유율 | 라벨에 필요 없다. 코퍼스 가명 차량과 외부 정원 키의 조인은 0/77이다. 관측 최대 좌석도 정원이 아니라 하한이라 금지했다. |
crowded | 대상 노선은 유형 11인데 공식 제공 대상은 13·15·23이다. 180초 이하 인접 81,147쌍에서 혼잡도만 바뀐 경우도 0건이다. 독립 정보로 쓰지 않는다. |
queryTime | 차량 시각이 아니다. 직접 수집 92호출에서 92/92건이 로컬 수신보다 미래였고 중앙 +17.265초였다. 되감김은 인접 호출 전체 16건, 같은 노선 안 2건이다. |
| 상류 버스 잔여석·직전 통과 상태 | 허용된 재료지만 이 후보에서는 뺐다. 시공간 면만으로 얻는 몫을 따로 재기 위해서다. |
그림 명세
| 입력 | 3330 · 순번 61 · 426분 · 계획 5분 |
|---|---|
| 회귀 점수 | z=.9572 |
| 만석확률 | .7226 |
| 탈 확률 | .2774 |
| 63초 뒤 정답 | 출발 후 0석 |
2026-08-03 코퍼스 검증행과 그 뒤 출발 관측이다.
5. 언제 실패하나
Q. 매끈하다는 가정은 어디서 깨지나?
- 회차점과 급변 정류장에서 깨진다. 3330 회차 순번 43처럼 방향이 바뀌거나 대량 하차가 생기면 실제 위험은 튈 수 있다. 스플라인은 그 턱을 눌러 버린다.
- 새 노선·새 마스터·새 시간창에 약하다. 코퍼스 검증자료의 새 셀은 2행이고 만석은 0건이었다. 외삽 성능은 아직 재지 못했다.
- 사고·행사·날씨·실제 지연을 모른다. 같은 정류장·시각이라도 오늘만 생긴 급변은 따라가지 못한다.
- 현재 상류 버스의 잔여석과 직전 통과 상태를 안 쓴다. 실시간 국면이 평소 시공간 면과 다르면 늦게 반응한다.
- 직접 수집분 재대결에서 학습 1,081건·만석 40건, 검증 443건·만석 14건으로 AUC .8873, ECE .0715였다. 21쌍 중 유의하게 밀린 상대는 재료 다 넣기 하나이고 나머지는 가리지 못했다.
- 코퍼스 보조에서도 이득은 작았다. AUC와 ECE 점추정은 좋아졌지만 Brier와 log loss는 기준선과 같았다. 이 모델만으로 후보 경쟁을 끝낼 근거는 아니다.
6. 프로토타입과 무엇이 다른가
Q. 예전 좌석 예보를 곡선으로 바꾼 모델인가?
아니다. 목표부터 더 좁다.
| 프로토타입 | GAM/스플라인 |
|---|---|
화면 탑승 확률은 정류장×평일/주말×30분 셀의 1-z/n이었다. | 출발 후 만석확률 하나를 직접 학습하고 1-p_full만 탈 확률로 바꾼다. |
| 차량별 현재 좌석에서 0~80석 분포를 정류장마다 전파했다. | 현재 좌석을 전파하지 않는다. 위치와 시간에서 바로 확률을 낸다. |
| 셀을 낱개로 조회하고, 근거가 없을 때만 이웃 시간대를 합쳤다. | 모든 관측을 연속 공간·시간 기저 위에 놓고 늘 이웃과 계수를 나눠 쓴다. |
| 좌석값의 도착 전·출발 후 의미가 계산 경로마다 달랐다. | 정답을 출발 후 0석으로 고정했다. |
| 확률 보정층이 없었다. | raw·Platt·isotonic을 같은 시간순 검증에서 겨뤘고, 이 모델은 raw를 남겼다. |
GAM은 프로토타입의 좌석 시뮬레이터를 다듬은 버전이 아니다. 얇은 정류장×시간 셀을 다루기 위해 만든 직접 확률 모형이다.
재료 한눈에
아직 못 써본 것과 그 이유
- 정류장 ID·이름 — 범주를 외우지 않고 순번의 연속성을 시험하는 모델이다.
- 정원·점유율 — 라벨에 필요 없다. 코퍼스 가명 차량과 외부 정원 키의 조인은 0/77이다. 관측 최대 좌석도 정원이 아니라 하한이라 금지했다.
- crowded — 대상 노선은 유형 11인데 공식 제공 대상은 13·15·23이다. 180초 이하 인접 81,147쌍에서 혼잡도만 바뀐 경우도 0건이다. 독립 정보로 쓰지 않는다.
- queryTime — 차량 시각이 아니다. 직접 수집 92호출에서 92/92건이 로컬 수신보다 미래였고 중앙 +17.265초였다. 되감김은 인접 호출 전체 16건, 같은 노선 안 2건이다.
- 상류 버스 잔여석·직전 통과 상태 — 허용된 재료지만 이 후보에서는 뺐다. 시공간 면만으로 얻는 몫을 따로 재기 위해서다.
아래 성적은 남의 20일 코퍼스로 낸 것이다. 그 코퍼스는 차량 ID가 가명이라 정원·차종·저상 여부를 붙일 수 없었다(조인 0/77). 이 재료들은 테스트 자체를 못 했다.
우리 직접 수집분에는 원본 차량 ID가 남아 정원이 실제로 붙는다 — 고유차량 55/62대(88.7%). 다만 1회차 재대결에서 정원·차종을 넣어도 뚜렷한 개선은 확인되지 않았다. 표본이 얇아 판정이 안 될 뿐, 쓸모없다는 뜻은 아니다. 관측 최대 잔여석으로 정원을 추정하는 것은 계속 금지다 — 원본 ID를 써도 그 값은 관측할수록 올라가서 정원이 아니다.
직접 수집분으로 다시 잰 성적 1회차
우리 수집기가 직접 쌓은 데이터만 쓰고 코퍼스는 뺐다. 검증분 443행·만석 14건, 정류소×날짜 125군집 부트스트랩 5,000회.
| 지표 | 직접 수집분 (95% 구간) | 코퍼스 |
|---|---|---|
| 확률이 정직한가 | 0.0715[0.0471, 0.1013] | 0.0038 |
| 얼마나 빗나갔나 | 0.0531[0.0332, 0.0742] | 0.0328 |
| 확신하고 틀린 벌점 | 0.1609[0.1082, 0.2165] | 0.1034 |
| 만석을 골라내나 | 0.8873[0.8397, 0.9322] | 0.9647 |
표본이 얇아 21쌍 중 16쌍이 구분되지 않았다. 검증 날짜가 하루뿐이라 날짜 간 변동도 못 본다. 21쌍 중 16쌍을 못 가리고 validation 날짜가 1개라 전체 순위를 발표하지 않는다
코퍼스 순위가 뒤집힌 쌍은 0개다. 방향은 틀리지 않았고 미세한 차이만 미지로 남았다. ΔAUC 0.05를 가리려면 8 수집일, 날짜 변동까지 보려면 15일이 더 든다.
쓰는 재료 6개 — 출처 필드까지
| 재료 | 종류 | 어느 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 마스터 |
목표 축 순번 (station_seq)stateCd=1이면 현재 stationSeq-1, stateCd=0·2이면 현재 stationSeq를 마지막 통과 순번으로 놓는다. 인접 적격 응답에서 이 값이 전진한 끝점을 통과 사건 순번으로 삼는다.주의 — 모델 축은 routestation 전체 순번이다. 학습 행은 서비스 정차점만 남겨도 축 간격에는 미정차·경유 지점이 들어간다. | 파생 | msgBody.busLocationList[].vehId, msgBody.busLocationList[].stationSeq, msgBody.busLocationList[].stateCd; routestation.staOrder; S1 station_seqGBIS 버스위치정보 getBusLocationListv2 + GBIS routestation 마스터 + S1 통과 사건 빌더 |
KST 하루 중 분 (query_minute_of_day)KST 시각에서 hour*60+minute을 계산한다.주의 — msgHeader.queryTime은 쓰지 않는다. 초는 버린다. | 파생 | timing.response_received_at.kst, timing.response_received_at.utc; S1 E4 response_received_kst, response_received_utc; 코퍼스 collectedAt; S1 query_minute_of_day우리 수집기 응답 수신시각; 제한 코퍼스는 수집기가 붙인 collectedAt |
요일 (query_day_of_week)KST 날짜에 Python weekday()를 적용한다. 월요일 0, 일요일 6이다.주의 — 공휴일 표시는 없다. 요일만 구분한다. | 파생 | timing.response_received_at.kst, timing.response_received_at.utc; S1 E4 response_received_kst, response_received_utc; 코퍼스 collectedAt; S1 query_day_of_week우리 수집기 응답 수신시각; 제한 코퍼스는 수집기가 붙인 collectedAt |
계획 배차간격 (master_dispatch_interval_min)현재 구현은 평일 06:30~09:29·17:00~20:29에 peekAlloc, 그 밖 평일에 npeekAlloc을 고른다. 토요일은 satNpeekAlloc, 일요일은 sunNpeekAlloc을 고른다.주의 — 계획값이지 실제 헤드웨이가 아니다. 현재 코드의 피크 조건에 평일 검사가 들어가 토·일 *PeekAlloc 분기는 닿지 않는다. we*도 고르지 않는다. | 파생 | route.peekAlloc, route.npeekAlloc, route.satPeekAlloc, route.satNpeekAlloc, route.sunPeekAlloc, route.sunNpeekAlloc, route.wePeekAlloc, route.weNpeekAlloc; timing.response_received_at.kst, timing.response_received_at.utc; S1 master_dispatch_interval_minGBIS route 마스터 + 우리 수집기 응답 수신시각 + S1 특징 빌더 |
출발 후 만석 라벨 (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개
- 차량 정원·점유율 —
TBBMSVEHINFOM.SEAT_RDNG_PSN_CNT; 점유율 후보식1-msgBody.busLocationList[].remainSeatCnt/TBBMSVEHINFOM.SEAT_RDNG_PSN_CNT20일 코퍼스 차량키가plateNo우선 HMAC이라 외부 정원과 조인된 차량이 0/77이다. 기존 231,048행의capacity_exact도 모두 0이다. 관측 최대 잔여석은 정원의 하한일 뿐 고정 정원이 아니다. 라벨은 출발 후 좌석이 0인지에 정원이 필요 없다. 프로토타입의defaultSeatCapacity=80은 분포 배열 상한이며 점유율 분모가 아니다. - 혼잡도 — GBIS getBusLocationListv2
msgBody.busLocationList[].crowded공식 제공 노선유형은 13·15·23이고 대상 노선은 11이다. 직접 수집분에서 같은 잔여석 값에 다른 코드가 붙은 값이 26개 나와 잔여석만의 전역 함수라는 해석은 반증됐다. 다만 같은(vehId, remainSeatCnt)에서는 충돌이 0셀이고crowded만 바뀐 연속쌍도 0건이라 차량별 임계화일 가능성이 남는다. 산식은 미확정이고 아직 특징으로 써 보지 않았다. - API queryTime 파생 시간 — GBIS getBusLocationListv2
msgHeader.queryTime; HTTP 응답 헤더Date같은 노선 연속 38쌍 중 7쌍에서queryTime이 뒤로 갔다. 직접 수집 92/92건에서는 응답 수신시각보다 미래였고 중앙 +17.265초였다. HTTPDate는queryTime의 초 절삭과 92/92 일치해 독립 시계가 아니다. 감사 원문만 보존하고 나이·속도·헤드웨이·분할에 쓰지 않는다. - 정류장 ID·이름 자체 —
routestation.stationId,routestation.stationName; S1station_id,station_nameGAM은 정류장 범주 대신station_seq의 매끈한 함수를 쓴다. 이름은 조인키로도 쓰지 않는다. - 현재 상류·지난 버스 상태 —
msgBody.busLocationList[].remainSeatCnt,msgBody.busLocationList[].stateCd,msgBody.busLocationList[].stationSeq,msgBody.busLocationList[].vehId에서 만드는 S1upstream_*,passed_*,previous_passage_*,route_seat_*이 후보는 역사적 시공간 위험만 비교하려고 실시간 스냅샷 재료를 읽지 않는다.
언제 실패하나
- 회차점과 급변 정류장에서 깨진다. 3330 회차 순번 43처럼 방향이 바뀌거나 대량 하차가 생기면 실제 위험은 튈 수 있다. 스플라인은 그 턱을 눌러 버린다.
- 새 노선·새 마스터·새 시간창에 약하다. 코퍼스 검증자료의 새 셀은 2행이고 만석은 0건이었다. 외삽 성능은 아직 재지 못했다.
- 사고·행사·날씨·실제 지연을 모른다. 같은 정류장·시각이라도 오늘만 생긴 급변은 따라가지 못한다.
- 현재 상류 버스의 잔여석과 직전 통과 상태를 안 쓴다. 실시간 국면이 평소 시공간 면과 다르면 늦게 반응한다.
- 직접 수집분 재대결에서 학습 1,081건·만석 40건, 검증 443건·만석 14건으로 AUC .8873, ECE .0715였다. 21쌍 중 유의하게 밀린 상대는 재료 다 넣기 하나이고 나머지는 가리지 못했다.
- 코퍼스 보조에서도 이득은 작았다. AUC와 ECE 점추정은 좋아졌지만 Brier와 log loss는 기준선과 같았다. 이 모델만으로 후보 경쟁을 끝낼 근거는 아니다.
기존 프로토타입과 다른 점
- 화면 탑승 확률은 정류장×평일/주말×30분 셀의 1-z/n이었다. GAM은 출발 후 만석확률 하나를 직접 학습하고 1-p_full만 탈 확률로 바꾼다.
- 프로토타입은 차량별 현재 좌석에서 0~80석 분포를 정류장마다 전파했다. GAM은 현재 좌석을 전파하지 않고 위치와 시간에서 바로 확률을 낸다.
- 프로토타입은 셀을 낱개로 조회하고 근거가 없을 때만 이웃 시간대를 합쳤다. GAM은 모든 관측을 연속 공간·시간 기저 위에 놓고 늘 이웃과 계수를 나눠 쓴다.
- 프로토타입은 좌석값의 도착 전·출발 후 의미가 계산 경로마다 달랐다. GAM은 정답을 출발 후 0석으로 고정했다.
- 프로토타입에는 확률 보정층이 없었다. GAM은 raw·Platt·isotonic을 같은 시간순 검증에서 겨뤘고 raw를 남겼다.