o2o-negosium-original/agent/eval_harness/feature_buyer.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

105 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)