o2o-negosium-original/agent/negotiation/qtable/domain/service/reward_calculator.py
hbyang be1b4b968f [fix] agent: 보상함수를 q-table 상세 설명 v4 명세에 정합화
- 동적 가중치 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>
2026-07-02 14:10:44 +09:00

94 lines
3.9 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.

"""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 + β·(anchorP)/anchor (초과달성 보너스)
anchor ≤ P ≤ target → (targetP)/(targetanchor)
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,
)