o2o-negosium-original/agent/negotiation/qtable/domain/service/reward_calculator.py

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