- 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>
105 lines
5.4 KiB
Python
105 lines
5.4 KiB
Python
"""FeatureBuyer — 카드 '내용(전략)'과 협력사 '프로필'에 반응하는 시뮬 협력사 (Phase 2·3).
|
||
|
||
효과를 2축으로 분리한다(성향 조건화가 의미를 가지려면 트레이드오프가 필요):
|
||
- 양보력(concession power): 이 카드가 가격을 얼마나 끌어내리는가
|
||
- 수락력(accept power) : 이 카드가 합의(수락) 확률을 얼마나 높이는가
|
||
|
||
전략별 기본 프로필(트레이드오프):
|
||
경쟁(1): 양보력↑↑ 수락력↓ — 세게 깎지만 결렬 위험
|
||
수용(2): 양보력↓ 수락력↑
|
||
고수(3): 양보력·수락력 중간
|
||
협력(4): 양보력↓ 수락력↑↑ — 잘 성사되지만 덜 깎임
|
||
|
||
여기에 협력사 세그먼트 적합도(AFFINITY)가 곱해진다: 전략이 그 협력사에 안 맞으면 둘 다 죽는다.
|
||
소형·경쟁多 → 경쟁압박이 잘 먹힘 / 대형·단독 → 협력이 잘 먹힘(압박 역효과)
|
||
|
||
→ '가격 중시' 고객사는 경쟁 카드(많이 깎음, 결렬 감수), '성사 중시' 고객사는 협력 카드가 정답이
|
||
되는 구조. 에이전트는 카드 특징 + 협력사 특징 + 고객사 성향으로 이를 학습해야 한다.
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Dict, Tuple
|
||
|
||
import numpy as np
|
||
|
||
from eval_harness.buyer import BuyerResponse, Scenario
|
||
|
||
# strategy_type: 1=경쟁, 2=수용, 3=고수, 4=협력 (card.nego_cards)
|
||
# 전략별 (양보력, 수락력) 기본 프로필 — 트레이드오프의 원천
|
||
STRATEGY_PROFILE: Dict[int, Tuple[float, float]] = {
|
||
1: (0.90, 0.25), # 경쟁: 세게 깎지만 성사 어려움
|
||
2: (0.35, 0.70), # 수용
|
||
3: (0.55, 0.50), # 고수: 중간
|
||
4: (0.30, 0.90), # 협력: 잘 성사되지만 덜 깎임
|
||
}
|
||
|
||
# 세그먼트별 전략 적합도 m ∈ [0,1] — 전략이 그 협력사에 얼마나 '먹히는가'
|
||
AFFINITY: Dict[Tuple[str, str], Dict[int, float]] = {
|
||
("small", "multi"): {1: 0.90, 2: 0.45, 3: 0.60, 4: 0.40}, # 소형·경쟁多 → 경쟁압박
|
||
("small", "single"): {1: 0.35, 2: 0.60, 3: 0.80, 4: 0.55}, # 소형·단독 → 고수/논리
|
||
("big", "multi"): {1: 0.65, 2: 0.50, 3: 0.70, 4: 0.60},
|
||
("big", "single"): {1: 0.20, 2: 0.70, 3: 0.50, 4: 0.90}, # 대형·단독 → 협력 (압박 역효과)
|
||
}
|
||
REVENUE_BIG = 50_000_000 # state config 'high' 경계와 정합
|
||
|
||
|
||
@dataclass
|
||
class SupplierProfile:
|
||
"""협력사 프로필 — 에피소드마다 달라지는 협상 상대. snapshot 필드와 정합."""
|
||
|
||
revenue_amount: float
|
||
partner_count: int # 이 품목의 대안 협력사 수 (BATNA. 1:1 채팅이어도 다양)
|
||
distribution_code: str
|
||
|
||
@property
|
||
def segment(self) -> Tuple[str, str]:
|
||
size = "big" if self.revenue_amount > REVENUE_BIG else "small"
|
||
comp = "multi" if self.partner_count >= 2 else "single"
|
||
return (size, comp)
|
||
|
||
|
||
def sample_supplier(rng: np.random.Generator) -> SupplierProfile:
|
||
"""무작위 협력사 생성 (학습 데이터 다양성)."""
|
||
return SupplierProfile(
|
||
revenue_amount=float(rng.choice([5_000_000, 20_000_000, 80_000_000, 200_000_000])),
|
||
partner_count=int(rng.choice([1, 1, 2, 3])), # 단독 비중 높게
|
||
distribution_code=str(rng.choice(["A", "B", "C"])),
|
||
)
|
||
|
||
|
||
class FeatureBuyer:
|
||
"""전략 프로필 x 세그먼트 적합도 기반 협력사 모델. (양보력, 수락력) 2축."""
|
||
|
||
def __init__(self, supplier: SupplierProfile, card_strategy: Dict[str, int], seed: int = 0,
|
||
accept_base: float = 0.08, max_turns: int = 5, jitter: float = 0.05):
|
||
self.supplier = supplier
|
||
self.card_strategy = card_strategy # {card_number: strategy_type}
|
||
self.rng = np.random.default_rng(seed)
|
||
self.accept_base = accept_base
|
||
self.max_turns = max_turns
|
||
self.jitter = jitter
|
||
# 숨은 하한가(reservation): 앵커의 94~110%. 앵커보다 높으면(약 60%) 가격만으로는
|
||
# 타결 불가 → 수락을 받아내야 함 → 수락력 낮은(경쟁) 카드에 진짜 결렬 위험이 생긴다.
|
||
self.floor_ratio = float(self.rng.uniform(0.94, 1.10))
|
||
|
||
def powers(self, card_number: str) -> Tuple[float, float]:
|
||
"""숨은 (양보력, 수락력). 전략 프로필 × 세그먼트 적합도 + 카드별 결정론적 지터."""
|
||
strat = self.card_strategy.get(card_number, 3)
|
||
conc_base, acc_base = STRATEGY_PROFILE.get(strat, (0.5, 0.5))
|
||
m = AFFINITY[self.supplier.segment].get(strat, 0.5)
|
||
scale = 0.35 + 0.85 * m # 적합도: 안 맞으면 둘 다 죽음 (0.35~1.2)
|
||
j = (hash(card_number) % 1000 / 1000.0 - 0.5) * 2 * self.jitter
|
||
c_pow = float(np.clip(conc_base * scale + j, 0.02, 0.98))
|
||
a_pow = float(np.clip(acc_base * scale + j, 0.02, 0.98))
|
||
return c_pow, a_pow
|
||
|
||
def respond(self, card_number: str, scenario: Scenario, turn: int, current_price: float) -> BuyerResponse:
|
||
c_pow, a_pow = self.powers(card_number)
|
||
floor = scenario.anchor_price * self.floor_ratio # 숨은 하한가 (앵커 이하 보장 없음)
|
||
concession = (current_price - floor) * (0.10 + 0.55 * c_pow)
|
||
new_price = max(floor, current_price - concession)
|
||
p_accept = min(0.97, self.accept_base + 0.80 * a_pow + 0.05 * (turn - 1))
|
||
accept = bool(self.rng.random() < p_accept)
|
||
walked = (not accept) and (turn >= self.max_turns)
|
||
return BuyerResponse(accept=accept, new_price=new_price, walked=walked)
|