- 동적 가중치 W: 라운드 감쇠 → 상태 5차원 가중합 clip(Σwᵢ·Sᵢ, 0.2, 0.8) (식 12~13, 기존 w1~w5 연결) - 종료보상에 (1−W) 적용: R = W×R_price + (1−W)×R_end − λ×round (식 8) - R_price 3단계: P<anchor 시 1+β·(anchor−P)/anchor 초과달성 보너스 추가 (식 9~11, beta 의미 재정의) - price zone 경계는 명세(T)와 달리 anchor 유지(우선협상 규칙이 실제 의사결정 경계) — 사유 docstring 명시 - state_calculator/config 의 낡은 반대 컨벤션(anchor≥target) 주석 정정 - RewardCalculator(RewardConfig, StateConfig) 시그니처 변경 + 호출부 5곳 갱신, 테스트 기대값 정정 (76/76 PASS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
"""RewardCalculator — 협상 라운드 보상 계산 (설계 명세 v4 정합, RewardConfig/StateConfig 주입).
|
||
|
||
클린룸: 보상 공식의 '형태'(가격보상 + 종료보상 - 페널티, 동적 가중치)는 기능적 아이디어이고,
|
||
아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 config 에서 주입.
|
||
|
||
산식 (q-table 상세 설명 v4 식 (8)~(14)):
|
||
R = W × R_price + (1 − W) × R_end − R_penalty
|
||
R_price 3단계: P < anchor → 1 + β·(anchor−P)/anchor (초과달성 보너스)
|
||
anchor ≤ P ≤ target → (target−P)/(target−anchor)
|
||
P > target → 0
|
||
W = clip(Σ wᵢ·Sᵢ, min_weight, max_weight) — Sᵢ 는 각 state 차원의 정규화 가중치
|
||
R_penalty = λ × round
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
|
||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||
from negotiation.qtable.domain.service.state_calculator import build_state
|
||
from tenancy.config import RewardConfig, StateConfig
|
||
|
||
|
||
@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, state_config: StateConfig):
|
||
self._cfg = config
|
||
self._state_cfg = state_config
|
||
|
||
def _price_reward(self, snapshot: NegotiationSnapshot) -> float:
|
||
"""KT 구매자 관점: 협력사 제시가가 낮을수록 보상↑ (anchor 앵커링가 < target 목표 매입가).
|
||
|
||
3단계 차등 (식 9~11): 앵커 미만이면 기본 1.0 + β 보너스, 범위 내 선형, 목표 초과면 0.
|
||
"""
|
||
anchor, target, price = snapshot.anchor_price, snapshot.target_price, snapshot.input_price
|
||
band = target - anchor
|
||
if band <= 0 or anchor <= 0:
|
||
return 0.0
|
||
if price < anchor:
|
||
return 1.0 + self._cfg.beta * (anchor - price) / anchor
|
||
if price <= target:
|
||
return (target - price) / band
|
||
return 0.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:
|
||
"""5개 state 차원의 정규화 가중치(Sᵢ) × 메타 가중치(wᵢ) 가중합 (식 12~13).
|
||
|
||
W_raw = w1·S_revenue + w2·S_dist + w3·S_partner + w4·S_accept + w5·S_pricezone,
|
||
[min_weight, max_weight] 로 클립. W 가 클수록 가격보상, 작을수록 종료보상 비중↑.
|
||
"""
|
||
st = build_state(snapshot, self._state_cfg)
|
||
s = self._state_cfg
|
||
w_raw = (
|
||
self._cfg.w1 * s.revenue.weights[st.revenue_idx]
|
||
+ self._cfg.w2 * s.distribution.weights[st.distribution_idx]
|
||
+ self._cfg.w3 * s.partner.weights[st.partner_idx]
|
||
+ self._cfg.w4 * s.acceptance.weights[st.acceptance_idx]
|
||
+ self._cfg.w5 * s.price_zone.weights[st.price_zone_idx]
|
||
)
|
||
return _clip(w_raw, 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 + (1.0 - weight) * end_reward - penalty
|
||
return RewardBreakdown(
|
||
price_reward=price_reward,
|
||
end_reward=end_reward,
|
||
penalty=penalty,
|
||
weight=weight,
|
||
total=total,
|
||
)
|