75 lines
3.0 KiB
Python
75 lines
3.0 KiB
Python
"""RewardCalculator — 협상 라운드 보상 계산 (우리 자체 공식, RewardConfig 주입).
|
|
|
|
클린룸: 보상 공식의 '형태'(가격보상 + 종료보상 - 페널티, 동적 가중치)는 기능적 아이디어이고,
|
|
아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 RewardConfig 에서 주입.
|
|
|
|
PoC 의도: 학습 루프가 협상 성과(타결 여부·타결가·턴 수)를 보상으로 흡수하는지 검증.
|
|
산식은 결정론적이며 RewardConfig 로 튜닝 가능하다.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
|
from tenancy.config import RewardConfig
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RewardBreakdown:
|
|
"""보상 구성요소(투명성/디버깅용). total 이 학습에 쓰인다."""
|
|
|
|
price_reward: float
|
|
end_reward: float
|
|
penalty: float
|
|
weight: float
|
|
total: float
|
|
|
|
|
|
def _clip(v: float, lo: float, hi: float) -> float:
|
|
return max(lo, min(hi, v))
|
|
|
|
|
|
class RewardCalculator:
|
|
def __init__(self, config: RewardConfig):
|
|
self._cfg = config
|
|
|
|
def _price_reward(self, snapshot: NegotiationSnapshot) -> float:
|
|
"""KT 구매자 관점: 협력사 제시가가 낮을수록 보상↑ (anchor 앵커링가 < target 목표 매입가).
|
|
|
|
progress = clip((target - input) / (target - anchor), 0, 1).
|
|
제시가가 목표가면 0, 앵커링가까지 내려오면 1, 더 낮으면 1로 캡.
|
|
"""
|
|
band = snapshot.target_price - snapshot.anchor_price
|
|
if band <= 0:
|
|
return 0.0
|
|
return _clip((snapshot.target_price - snapshot.input_price) / band, 0.0, 1.0)
|
|
|
|
def _end_reward(self, outcome: NegotiationOutcome) -> float:
|
|
if outcome == NegotiationOutcome.SUCCESS:
|
|
return self._cfg.success_reward
|
|
if outcome == NegotiationOutcome.FAILURE:
|
|
return self._cfg.failure_penalty
|
|
return self._cfg.ongoing_reward
|
|
|
|
def _dynamic_weight(self, snapshot: NegotiationSnapshot) -> float:
|
|
"""라운드가 진행될수록 가중치를 beta 만큼 감쇠해 [min_weight, max_weight] 로 클립.
|
|
|
|
w = max_weight - beta * round_number → 초반 라운드일수록 가격보상을 더 크게 본다.
|
|
(w1~w5 는 차원별 가중치로 P-후속 단계에서 state 차원 중요도에 결합 예정.)
|
|
"""
|
|
w = self._cfg.max_weight - self._cfg.beta * max(0, snapshot.round_number)
|
|
return _clip(w, self._cfg.min_weight, self._cfg.max_weight)
|
|
|
|
def calculate(self, snapshot: NegotiationSnapshot) -> RewardBreakdown:
|
|
price_reward = self._price_reward(snapshot)
|
|
end_reward = self._end_reward(snapshot.outcome)
|
|
weight = self._dynamic_weight(snapshot)
|
|
penalty = self._cfg.penalty_lambda * max(0, snapshot.round_number)
|
|
total = weight * price_reward + end_reward - penalty
|
|
return RewardBreakdown(
|
|
price_reward=price_reward,
|
|
end_reward=end_reward,
|
|
penalty=penalty,
|
|
weight=weight,
|
|
total=total,
|
|
)
|