o2o-negosium-original/agent/negotiation/qtable/domain/service/feature_builder.py
jwkim d4acdd0ac5 [feat] agent: 카드 선택 DQN 서빙 전환 (action-as-feature)
- feature_dqn_policy: ScoreNet(상태+카드특징 → 점수) 학습 정책 (replay+타깃넷)
- feature_builder: 이산화 없는 연속 상태 벡터(9) + 테넌트 성향 벡터(5)
- dqn_store: numpy 전용 서빙(컨테이너 PyTorch 불필요), DQN_SERVING 플래그,
  미지원 테넌트는 Q-table 자동 폴백
- 파이프라인: build_card_embeddings -> train_feature_dqn -> export_dqn_serving(npz)
- retrain_from_logs: 실로그 재학습 + OPE(SNIPS) 게이트, 통과 시에만 번들 교체(.prev 백업)
- probe_serving_dqn / compare_qtable_vs_dqn: 배포 전 행동 점검 도구

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:56:24 +09:00

45 lines
2.4 KiB
Python

"""build_state_features — snapshot(raw 연속값) → 실수 벡터 (DQN/action-as-feature 용).
state_calculator.build_state(이산화)와 대비되는 연속 표현. 이산화(등급/162칸)를 하지 않고
정규화된 raw 값을 그대로 벡터로 내보낸다. 협력사 특징(매출·경쟁사수·유통)이 벡터에 포함되므로
'협력사를 입력으로'(Phase 3)가 자연스럽게 달성된다.
"""
import numpy as np
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
DIST_CLASSES = ("A", "B", "C")
STATE_FEATURE_DIM = 9 # build_state_features 벡터 길이. feature 추가 시 갱신.
TENANT_FEATURE_DIM = 5 # build_tenant_features 벡터 길이.
def build_tenant_features(reward_cfg) -> np.ndarray:
"""고객사 '성향'을 ID 가 아니라 보상 설정값(내용)으로 벡터화 (Phase 3 고객사 조건화).
새 고객사도 tenant.yaml 의 reward 설정만 있으면 즉시 조건화된다 (cold-start 없음).
"""
return np.array([
reward_cfg.max_weight, # 가격 중시 정도 (W↑ = 가격보상 비중↑)
reward_cfg.success_reward / 2.0, # 성사를 얼마나 크게 치는가
-reward_cfg.failure_penalty / 2.0, # 결렬을 얼마나 무서워하는가
reward_cfg.penalty_lambda * 20.0, # 속도 성향 (오래 끌수록 벌점)
reward_cfg.beta, # 앵커 초과달성 보너스 성향
], dtype=np.float32)
def build_state_features(s: NegotiationSnapshot) -> np.ndarray:
"""정규화된 연속 상태 벡터. 등급화 없음 — 990원과 850원이 구별된다."""
dist_onehot = [1.0 if s.distribution_code == c else 0.0 for c in DIST_CLASSES]
anchor = max(s.anchor_price, 1.0)
target = max(s.target_price, 1.0)
return np.array([
min(s.revenue_amount, 5e8) / 5e8, # 협력사 매출 (0~1)
*dist_onehot, # 유통 A/B/C
min(s.partner_count, 5) / 5.0, # 대안 협력사 수 (BATNA)
float(np.clip(s.acceptance_ratio, 0.0, 1.0)), # 수용률
float(np.clip((s.input_price - anchor) / anchor, -1.0, 2.0)), # 앵커 대비 격차 (연속!)
float(np.clip((target - s.input_price) / target, -2.0, 1.0)), # 목표 대비 여유
min(s.round_number, 10) / 10.0, # 라운드
], dtype=np.float32)