Auto-Tuner 해부 ③: Grid Search 엔진 — Impact, Dampening, Silence 최적화
Grid Search로 Impact, Dampening, Silence Penalty를 최적화하는 핵심 엔진을 해부한다. Phase별 동적 범위, 분리도 시뮬레이션, Compound Score의 수학적 구조를 코드 레벨에서 상세 해설.
이전 편: ② Signal Lift 해부에서 시그널의 판별력 분석을 다루었다. 이 편에서는 Auto-Tuner의 심장 — Grid Search 최적화 엔진을 해부한다.
1. Grid Search란
1.1 원리
"파라미터를 조금씩 바꿔보고, 결과가 가장 좋은 값을 추천한다."
이것이 Grid Search의 전부다. 복잡한 수학이 필요 없고, 직관적으로 이해 가능하며, 결과의 이유를 명확히 설명할 수 있다.
for 후보값 in 탐색범위:
분리도 = 시뮬레이션(현재파라미터를 후보값으로 교체)
if 분리도 > 최고기록:
최고기록 = 분리도
최적값 = 후보값
추천 = 최적값
1.2 왜 더 고급 방법을 쓰지 않는가?
Gradient Descent, Bayesian Optimization, 유전 알고리즘 등 더 정교한 방법들이 있다. Auto-Tuner가 Grid Search를 고수하는 이유:
| 기준 | Grid Search | Gradient Descent |
|---|---|---|
| 설명 가능성 | ✅ "이 값에서 분리도가 최대" | ❌ "알고리즘이 수렴한 값" |
| 전역 최적 보장 | ✅ 범위 내 전수 탐색 | ❌ 로컬 최적에 빠질 수 있음 |
| 미분 가능성 필요 | ❌ 불필요 | ✅ 필수 |
| 속도 ((N+2)차원) | ⚠️ 1차원씩 순차 | ✅ 동시 최적화 |
Auto-Tuner는 1차원씩 순차 최적화한다 (coordinate descent). 상호작용 효과를 무시하지만, 각 추천의 이유를 개별적으로 설명할 수 있다.
MCMC(6편)가 이 한계를 보완한다 — (N+2)차원 동시 추정 ( = No Signal 제외 Impact 타입 수).
2. Impact 최적화
2.1 목적
Impact는 시그널의 **무게(가중치)**다. Game Changer의 Impact = 5.0이라는 건, 하나의 Game Changer 시그널이 α에 5.0을 추가한다는 뜻이다.
이 5.0이 정말 최적인가? 4.0이면 분리도가 더 높을 수도 있고, 6.0이 더 나을 수도 있다.
2.2 탐색 범위: Phase별 동적 확장
데이터가 적을 때 넓은 범위를 탐색하면 과적합 위험이 있다. Phase가 올라가며 범위를 확장:
| Phase | 범위 | 예시 (현재=5.0) | 이유 |
|---|---|---|---|
| 2 | ±20% | 4.0 ~ 6.0 | 소량 데이터, 보수적 |
| 3 | ±30% | 3.5 ~ 6.5 | 기본 범위 |
| 4 | ±40% | 3.0 ~ 7.0 | 충분한 데이터 |
| 5 | ±50% | 2.5 ~ 7.5 | 넓은 탐색 |
GRID_RANGE_RATIO_BY_PHASE = {
1 => BASE_GRID_RANGE_RATIO, # 0.20
2 => BASE_GRID_RANGE_RATIO, # 0.20
3 => 0.30,
4 => 0.40,
5 => 0.50
}.freeze
2.3 그리드 해상도
11개 포인트를 균등 분할한다:
현재값 = 5.0, 범위 = ±30% → [3.5, 3.8, 4.1, 4.4, 4.7, 5.0, 5.3, 5.6, 5.9, 6.2, 6.5]
왜 11개인가? 해상도와 속도의 균형. 5~6개면 거칠고, 50개면 시뮬레이션 시간이 50배. 11개면 충분한 정밀도에서 빠르게 실행된다.
2.4 시뮬레이션 1회
하나의 후보값에 대해:
def optimize_impact(impact_type, current_value)
range = current_value * grid_range_ratio # Phase별
candidates = (0..GRID_POINTS).map { |i|
low + (high - low) * i / GRID_POINTS
}
best_val = current_value
best_sep = current_separation[:separation]
candidates.each do |candidate|
override = { impact_type.id => candidate }
sep = simulate_separation(impact_override: override)
if sep > best_sep
best_sep = sep
best_val = candidate
end
end
{ current: current_value, recommended: best_val,
improvement: best_sep - current_separation[:separation] }
end
simulate_separation은 모든 Won/Lost 프로젝트를 처음부터 다시 시뮬레이션한다. 한 번 호출에 프로젝트 수 × 활동 수만큼의 연산이 실행된다.
2.5 최소 개선 임계값
분리도 개선이 MIN_SEPARATION_IMPROVEMENT = 0.01 미만이면, 현재값 유지를 추천한다.
왜? 0.003 같은 미세한 개선은 **통계적 잡음(noise)**일 가능성이 높다. 데이터 1건이 바뀌면 사라질 수 있는 수준이다.
action: improvement >= MIN_SEPARATION_IMPROVEMENT ? 'adjust' : 'keep'
3. Compound Score: Dampening의 수학
3.1 문제
한 미팅에서 시그널 3개가 동시에 나왔다:
- Game Changer (5.0)
- Strong Affirmation (1.0)
- Moderate Affirmation (0.7)
이 세 개를 모두 더하면 6.7이다. 하지만 이건 같은 맥락에서 나온 중복 정보일 수 있다. 고객이 매우 긍정적이어서 세 가지 시그널 모두 관찰된 것이지, 세 번의 독립적 긍정 사건이 아니다.
3.2 해법: MAX + 나머지 × dampening
def compound_with_dampening(scores, dampening)
return 0.0 if scores.empty?
sorted = scores.sort.reverse
# 가장 강한 시그널은 100% 반영
# 나머지는 dampening 비율만큼만 반영
sorted[0] + sorted[1..].sum * dampening
end
현재 dampening = 0.25:
Compound = 5.0 + (1.0 + 0.7) × 0.25
= 5.0 + 0.425
= 5.425
- dampening = 0: 5.0만 반영 (나머지 무시)
- dampening = 0.25: 5.425 (약간 반영)
- dampening = 1.0: 6.7 (전부 반영)
3.3 왜 MAX를 먼저 분리하는가?
가장 강한 시그널은 주도적 신호다. 이것은 전체 반영해야 한다. 나머지는 보조적 확인이므로 할인한다. 이것은 정보 이론의 한계 정보량(marginal information) 개념과 일치한다.
3.4 Dampening Grid Search
def optimize_dampening
candidates = (0..GRID_POINTS).map { |i| i.to_f / GRID_POINTS } # [0.0, 0.09, ..., 1.0]
best_dampening = BayesianUpdate::SIGNAL_DAMPENING
best_sep = current_separation[:separation]
candidates.each do |d|
sep = simulate_separation(dampening_override: d)
if sep > best_sep
best_sep = sep
best_dampening = d
end
end
{ current: BayesianUpdate::SIGNAL_DAMPENING,
recommended: best_dampening,
improvement: best_sep - current_separation[:separation] }
end
4. Silence Penalty 최적화
4.1 침묵의 패널티
고객을 일정 기간(기본 14일) 이상 만나지 않으면, β에 패널티가 추가된다:
여기서:
unit_penalty= Weak Negation 기본값 × silence_ratiocount= (경과일 - gap) / interval + 1
4.2 silence_ratio의 의미
silence_ratio = 0.3이고 Weak Negation = 0.4면:
unit_penalty = 0.4 × 0.3 = 0.12
14일 간격을 7일씩 3번 초과하면:
β += 0.12 × 3 = 0.36
이것은 "3번의 약한 부정 시그널을 받은 것과 같은 효과"라는 직관을 준다.
4.3 Grid Search
Silence ratio도 0.0~1.0 범위에서 11개 포인트를 탐색:
[0.0, 0.09, 0.18, ..., 0.91, 1.0]
silence_ratio = 0이면 침묵 패널티 없음, 1.0이면 Weak Negation 전체를 패널티로 부과.
5. 시뮬레이션 엔진 내부
5.1 simulate_separation 전체 흐름
simulate_separation(impact_override, dampening_override, silence_ratio_override)
├─ for 각 Won 프로젝트:
│ └─ p_win = simulate_project(project, overrides)
├─ for 각 Lost 프로젝트:
│ └─ p_win = simulate_project(project, overrides)
│
├─ won_avg = mean(Won p_win들)
├─ lost_avg = mean(Lost p_win들)
└─ return won_avg - lost_avg
5.2 simulate_project 한 줄씩
def simulate_project(project, impact_override, dampening, silence_ratio)
alpha = project.prior_alpha.to_f # ── ① Prior 초기값
beta = project.prior_beta.to_f
activities.each do |activity| # ── ② 시간순 활동 순회
swv = stage.swv.to_f # ── ③ 스테이지 가중치
# ── ④ Compound Score 계산
positive_compound, negative_compound =
simulate_compound_scores_fast(signal_ids, impacts, impact_override, dampening)
# ── ⑤ 침묵 패널티
if elapsed >= gap
count = ((elapsed - gap) / interval) + 1
beta += unit_penalty * count
end
# ── ⑥ α, β 업데이트
alpha += swv * positive_compound
beta += swv * negative_compound
end
alpha / (alpha + beta) # ── ⑦ P(Win) 반환
end
① → ② → ③ → ④ → ⑤ → ⑥ → ⑦ — 이것이 EXAWin 베이지안 엔진의 1 프로젝트 전체 생애주기 계산이다.
5.3 SWV (Stage Weight Value)
SWV는 스테이지별 가중치다. Discovery 단계에서의 시그널보다 Proposal 단계에서의 시그널이 더 결정적이다. SWV가 높을수록 같은 시그널이 α/β에 더 큰 영향을 준다.
6. 성능: 왜 빠른가
Auto-Tuner의 Grid Search는 최대:
ImpactType 수 × GRID_POINTS × 프로젝트 수 × 활동 수
= 9 × 11 × 100 × 20 = 198,000 연산
여기에 Dampening(11회), Silence(11회) 시뮬레이션을 더하면 약 22만 회 연산.
이것이 가능한 이유:
- 프리로딩: 모든 데이터가 메모리에 있음 (DB 쿼리 0)
- 단순 산술: 곱셈, 덧셈, 비교만 사용 (행렬 연산 불필요)
- 1차원 탐색: N차원 Grid는 이지만, 1차원씩 순차라
Ruby에서 22만 회 산술 연산은 1초 미만.
다음 편: ④ Threshold · k 해부 — Youden J 통계량, T 최적화, k Grid Search.