11. 학습 표본과 운영 표본의 불일치 — 수익률 필터가 만든 사후 선택

2026. 9. 13. 01:29카테고리 없음

화면 뒤의 데이터 판단 · 방향 신호

학습 표본과 운영 표본의 불일치 — 수익률 필터가 만든 사후 선택

0.5262 → 0.5166. 누수는 없었지만, 지금까지의 수치는 전부 사후 선택된 표본 위의 값이었다

모델 평가에서 가장 먼저 의심하는 것은 누수입니다. 미래 정보가 학습에 섞였는지를 봅니다. 그런데 누수가 하나도 없는데 성적이 부풀려지는 경로가 따로 있습니다. 학습·평가에 쓴 표본이 운영에서 만나는 표본과 다를 때입니다.

방향 신호 모델에서 그 경우를 만났습니다. 표본을 모으는 함수에 필터가 한 줄 있었습니다.

# 5봉 방향 학습 표본 — 움직임이 작은 날은 부호가 노이즈라고 보고 걸렀다.
# 학습에도 평가에도 같은 필터가 걸리므로 '누수'는 아니다.
mask = np.abs(fwd5) >= 0.02          # |5봉 수익| 2% 이상인 행만 pool 에 담는다
X, y = X[mask], (fwd5[mask] > 0).astype(int)

의도는 합리적입니다. 0.1% 움직인 날의 부호는 방향이라기보다 노이즈에 가깝고, 그런 행을 빼면 학습이 안정됩니다. 학습 구간과 평가 구간에 같은 필터를 걸었으니 미래 정보가 학습으로 새지도 않습니다.

문제는 다른 데 있었습니다. 어느 종목이 5일 안에 2% 넘게 움직일지는 그날 미리 알 수 없습니다. 운영에서 사용자가 보는 것은 "오늘 전 종목"이고, 그중 어느 것이 필터를 통과할지는 5일이 지나야 압니다. 이 필터는 결과를 보고 고른 표본이고, 그 위에서 잰 성적은 운영에서 재현되지 않습니다.

운영 모집단
전 종목 × 전 날짜
387,559행 — 사용자가 실제로 만나는 표본
└ 필터를 통과한 행
|5봉 수익| ≥ 2%
29,867행 — 5일이 지나야 어느 행인지 알 수 있다
필터본에서 잰 값 무작위10 AUC 0.5262 · 초과분 +0.0295  결과를 보고 고른 행 위의 성적
운영 모집단에서 다시 잰 값 무작위10 AUC 0.5166 · 초과분 +0.0186  필터 없이 47,227행 전부 평가

그림 1. 누수는 없었다. 다만 성적을 잰 무대가 운영 무대가 아니었다.

1. 대조군으로 원인을 분리한다

수치가 내려갔다는 사실만으로는 "표본 정의 때문"이라고 단정할 수 없습니다. 데이터를 다시 만드는 과정에서 다른 것이 바뀌었을 수도 있습니다. 그래서 같은 재료에 필터만 다시 거는 세 번째 조건을 넣었습니다.

표본 정의 초과분 무작위10 AUC 평가행
필터본 (지금까지 써 온 것) +0.0295 0.5262 29,867
무필터 (운영 모집단) +0.0186 0.5166 47,227
무필터에 2% 필터 재적용 (대조군) +0.0282 0.5247 29,867

세 번째 줄이 첫 줄을 재현합니다. 재료가 아니라 모집단 정의가 차이를 만들었다는 뜻입니다. 이 대조군이 없었다면 "새로 만든 데이터가 더 나빴나 보다"로 끝났을 것입니다.

2. 사후 필터의 이득은 신호가 아니었다

필터 임계를 올릴수록 성적이 올라갑니다. 단조 상승이라 더 그럴듯해 보입니다.

사후 임계 (결과를 보고 거름) 초과분 무작위10 AUC
0% (거르지 않음) +0.0186 0.5166
2% +0.0282 0.5247
4% +0.0388 0.5269

이 기울기를 "큰 움직임은 방향도 잘 맞는다"로 읽으면, 큰 움직임을 미리 골라 그 구간만 서비스하면 됩니다. 마침 변동성 예측 모델은 AUC 0.8020으로 꽤 잘 맞습니다. 재료는 있었습니다.

게이트 자체도 잘 작동했습니다. 예측 변동성 상위 10%로 좁히면 실제 |5봉| 중앙값이 2.99%에서 5.51%로 올라갑니다.

예측 변동성 게이트 실제 |5봉| 중앙값 2% 초과 비율
전체 2.99% 0.641
상위 30% 4.66% 0.768
상위 10% 5.51% 0.802

그런데 그렇게 고른 행들의 방향 성적은 반대로 갔습니다.

예측 변동성 게이트 (미리 고름) 초과분 무작위10 AUC
없음 +0.0186 0.5166
상위 50% +0.0145 0.5160
상위 30% +0.0121 0.5108
상위 20% +0.0088 0.5076
상위 10% +0.0107 0.5015
같은 크기 구간(중앙 |5봉| ≈ 5%)인데 결과를 보고 고르면 +0.0388, 미리 고르면 +0.0107입니다. 사후 필터의 이득은 예측력이 아니라 부호가 애매한 행을 지운 기계적 효과였습니다. 라벨을 보고 고르면 AUC가 오르고, 라벨을 안 보고 고르면 오히려 내려갑니다. 미리 고른 고변동성 구간은 방향이  어렵습니다.

3. 평가 장치의 결함도 하나 나왔다

게이트 실험 초판은 게이트를 예측 전에 걸었습니다. 그러면 횡단면 순위 정규화가 걸러진 부분집합 위에서 계산돼 피처 자체가 달라집니다. "국면을 골랐다"와 "순위가 나빠졌다"가 뒤섞여 게이트를 평가할 수 없습니다.

# 잘못된 순서 — 부분집합 위에서 순위를 다시 매기게 된다
rows = rows[gate_mask]
score = model.predict(rank_normalize(rows))

# 고친 순서 — 예측은 그날 전체 횡단면에서, 게이트는 평가 행에만
score = model.predict(rank_normalize(all_rows_of_the_day))
eval_rows = score[gate_mask]

고쳐서 재실행한 결과 결론은 바뀌지 않았습니다(상위 50% +0.0147 → +0.0145). 그래도 초판 로그를 지우지 않고 log_143_flawed.txt로 남겼습니다. 이 결함을 그대로 뒀다면 다음 실험인 확신도 게이트도 같은 구조로 오염됐을 것이기 때문입니다.

4. 지렛대가 아니었던 것들

성적이 내려갔으니 다른 축을 눌러 봅니다. 두 가지가 후보였고 둘 다 평평했습니다.

유니버스 크기 초과분 무작위10 AUC
100 종목 (3벌 평균) +0.0200 0.5190
200 종목 (3벌 평균) +0.0184 0.5161
300 종목 (3벌 평균) +0.0184 0.5181
391 종목 +0.0169 0.5166

신호가 전부 "같은 날 종목들 사이의 순위"라서 종목이 많을수록 순위가 정밀해질 것이라 봤는데 그렇지 않았습니다. 학습 기간도 마찬가지였습니다 — 2년 +0.0171 / 4년 +0.0160 / 전 기간 +0.0195. 개념 표류도 아니고 데이터 부족도 아니라는 뜻입니다.

5. 대신 얻은 것 — 라벨이 아니라 모델이 아는 값으로 고르기

이 배치가 알려준 원리는 하나입니다. 고르는 기준이 라벨에 닿으면 안 되고, 그 시점에 모델이 이미 아는 값이어야 한다. 가장 단순한 후보는 모델 자신의 확신도 |p − 0.5|입니다. 라벨을 보지 않으므로 운영에서 그대로 켤 수 있습니다.

확신도 게이트 초과분 무작위10 AUC 0.55 이상 비율
전체 +0.0186 0.5166 6.9%
상위 50% +0.0250 0.5218 16.9%
상위 30% +0.0263 0.5216 18.8%
상위 20% +0.0265 0.5190 18.4%

여기서 바로 "상위 30%를 쓰겠다"고 하면 네 줄을 다 보고 최선을 고른 것이라 그 자체가 선택 편향입니다. 그래서 구간을 갈랐습니다.

  • 선택 구간 — 옛 2년(창2·3)에서만 임계를 고른다. 고른 값: 상위 30%.
  • 완충 — 창1은 비워 둔다.
  • 판정 구간 — 최신 1년(창0)에 한 번만 적용한다.
판정 구간 (창0) 초과분 무작위10 AUC 0.55 이상 비율
게이트 없음 +0.0185 0.5162 4.0%
확신도 상위 30% +0.0275 0.5217 15.0%

선택 구간에서 본 이득이 판정 구간에서 같은 크기로 재현됐습니다. 사후 필터와 달리 이 게이트는 운영에서도 그대로 켤 수 있습니다 — 그날 그 시점에 계산 가능한 값만 쓰기 때문입니다.

6. 지표가 정직한지부터 확인했다

무작위10은 날것 AUC라 "귀무가 0.5"라는 보장이 없습니다. 라벨을 섞은 순열 모델의 예측으로 같은 지표를 다시 재서 귀무를 만들었습니다.

종목 수 실제 귀무 초과
0 391 0.5162 0.4934 +0.0228
1 385 0.5231 0.5024 +0.0207
2 380 0.5200 0.5001 +0.0199
3 377 0.5070 0.4990 +0.0080

귀무 평균 0.4987. 지표 자체에는 편향이 없습니다. 따라서 0.5166은 진짜 초과 +0.018이고, 목표 0.55는 지금의 약 2.8배를 요구한다는 뜻이 됩니다. 처음으로 목표까지의 거리를 배수로 환산할 수 있게 됐습니다.

7. 기록에 남긴 것

모델 파일은 dir5_linear_rank.pkl v5로 갱신하면서 두 가지를 함께 적었습니다.

  • conf_gate: 0.3 — 운영에서 켜는 확신도 임계.
  • correction  v4까지의 모든 수치가 필터된 모집단 위의 값이었다는 정정 기록.

정정을 지우고 새 수치만 남기면, 몇 달 뒤 옛 문서를 본 사람이 0.5262를 그대로 인용하게 됩니다. 내려간 수치를 남기는 쪽이 비용이 쌉니다.

정리

  • 누수가 없어도 성적은 부풀려진다 — 학습·평가에 같은 필터를 걸어도, 그 필터가 결과를 보고 거른 것이면 운영에서 재현되지 않는다.
  • 모집단을 먼저 정의한다 — "사용자가 실제로 만나는 행"이 무엇인지 정하고 그 위에서 잰다. 여기서는 전 종목 × 전 날짜.
  • 원인 분리는 대조군으로 — 같은 재료에 필터만 다시 걸어 옛 수치가 재현되는지 본다. 재현되면 원인은 재료가 아니라 정의다.
  • 사후 선택의 이득은 신호가 아니다 — 결과를 보고 고르면 +0.0388, 미리 고르면 +0.0107. 같은 크기 구간인데 방향이 반대로 갔다.
  • 고르는 기준은 모델이 아는 값으로 — 라벨에 닿지 않는 확신도 게이트는 판정 구간에서도 이득이 재현됐다.
  • 임계는 선택 구간에서 고르고 판정 구간에서 한 번만 쓴다 — 표를 다 보고 최선을 고르면 그 자체가 편향이다.
  • 지표의 귀무부터 확인한다 — 귀무 0.4987을 확인하고 나서야 0.5166이 초과 +0.018이라고 말할 수 있었다.
  • 내려간 수치는 정정으로 남긴다 — 모델 파일에 correction을 함께 저장했다.

방향 신호 모델 실험 141~150 기록 기준 · 무작위10 = 매일 무작위 10종목을 뽑아 잰 AUC