[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>
This commit is contained in:
parent
43ac308a5e
commit
be1b4b968f
@ -32,7 +32,7 @@ class EpisodeResult:
|
||||
def run_episode(policy, buyer: HeuristicBuyer, scenario: Scenario,
|
||||
state_cfg: StateConfig, reward_cfg: RewardConfig, action_space_size: int,
|
||||
max_turns: int = 5, learn: bool = True) -> EpisodeResult:
|
||||
rc = RewardCalculator(reward_cfg)
|
||||
rc = RewardCalculator(reward_cfg, state_cfg)
|
||||
episode = EpisodeState()
|
||||
total_r = 0.0
|
||||
# 협력사는 목표가보다 높게 시작(협상 여지). KT 가 카드로 앵커가 이하까지 끌어내린다.
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
"""RewardCalculator — 협상 라운드 보상 계산 (우리 자체 공식, RewardConfig 주입).
|
||||
"""RewardCalculator — 협상 라운드 보상 계산 (설계 명세 v4 정합, RewardConfig/StateConfig 주입).
|
||||
|
||||
클린룸: 보상 공식의 '형태'(가격보상 + 종료보상 - 페널티, 동적 가중치)는 기능적 아이디어이고,
|
||||
아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 RewardConfig 에서 주입.
|
||||
아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 config 에서 주입.
|
||||
|
||||
PoC 의도: 학습 루프가 협상 성과(타결 여부·타결가·턴 수)를 보상으로 흡수하는지 검증.
|
||||
산식은 결정론적이며 RewardConfig 로 튜닝 가능하다.
|
||||
산식 (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 tenancy.config import RewardConfig
|
||||
from negotiation.qtable.domain.service.state_calculator import build_state
|
||||
from tenancy.config import RewardConfig, StateConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -29,19 +35,24 @@ def _clip(v: float, lo: float, hi: float) -> float:
|
||||
|
||||
|
||||
class RewardCalculator:
|
||||
def __init__(self, config: RewardConfig):
|
||||
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 목표 매입가).
|
||||
|
||||
progress = clip((target - input) / (target - anchor), 0, 1).
|
||||
제시가가 목표가면 0, 앵커링가까지 내려오면 1, 더 낮으면 1로 캡.
|
||||
3단계 차등 (식 9~11): 앵커 미만이면 기본 1.0 + β 보너스, 범위 내 선형, 목표 초과면 0.
|
||||
"""
|
||||
band = snapshot.target_price - snapshot.anchor_price
|
||||
if band <= 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
|
||||
return _clip((snapshot.target_price - snapshot.input_price) / band, 0.0, 1.0)
|
||||
|
||||
def _end_reward(self, outcome: NegotiationOutcome) -> float:
|
||||
if outcome == NegotiationOutcome.SUCCESS:
|
||||
@ -51,20 +62,28 @@ class RewardCalculator:
|
||||
return self._cfg.ongoing_reward
|
||||
|
||||
def _dynamic_weight(self, snapshot: NegotiationSnapshot) -> float:
|
||||
"""라운드가 진행될수록 가중치를 beta 만큼 감쇠해 [min_weight, max_weight] 로 클립.
|
||||
"""5개 state 차원의 정규화 가중치(Sᵢ) × 메타 가중치(wᵢ) 가중합 (식 12~13).
|
||||
|
||||
w = max_weight - beta * round_number → 초반 라운드일수록 가격보상을 더 크게 본다.
|
||||
(w1~w5 는 차원별 가중치로 P-후속 단계에서 state 차원 중요도에 결합 예정.)
|
||||
W_raw = w1·S_revenue + w2·S_dist + w3·S_partner + w4·S_accept + w5·S_pricezone,
|
||||
[min_weight, max_weight] 로 클립. W 가 클수록 가격보상, 작을수록 종료보상 비중↑.
|
||||
"""
|
||||
w = self._cfg.max_weight - self._cfg.beta * max(0, snapshot.round_number)
|
||||
return _clip(w, self._cfg.min_weight, self._cfg.max_weight)
|
||||
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 + end_reward - penalty
|
||||
total = weight * price_reward + (1.0 - weight) * end_reward - penalty
|
||||
return RewardBreakdown(
|
||||
price_reward=price_reward,
|
||||
end_reward=end_reward,
|
||||
|
||||
@ -45,9 +45,11 @@ def _partner_bucket(count: int) -> int:
|
||||
|
||||
|
||||
def _price_zone_bucket(input_price: float, anchor_price: float, target_price: float) -> int:
|
||||
"""입력가격 구간 (KT 구매자 관점, anchor=협력사 기준가 ≥ target=KT 목표 매입가).
|
||||
"""입력가격 구간 (KT 구매자 관점, anchor 앵커링가 < target 목표 매입가).
|
||||
|
||||
협력사 제시가가 앵커가 이하면 우선협상 가능 구간(0), 초과면 추가 협상 구간(1).
|
||||
협력사 제시가가 앵커링가 이하면 우선협상(즉시 타결) 구간(0), 초과면 협상 지속 구간(1).
|
||||
설계 명세 v4 는 target 을 경계로 두지만, 확정 경제모델(제시가≤anchor→우선협상 타결)의
|
||||
실제 의사결정 경계는 anchor 이므로 의도적으로 anchor 를 경계로 사용한다.
|
||||
"""
|
||||
if anchor_price <= 0 or target_price <= 0:
|
||||
raise ValueError("anchor_price/target_price must be positive")
|
||||
|
||||
@ -135,7 +135,7 @@ class ChatService:
|
||||
decision = policy.select(ctx)
|
||||
session.used_action_ids.add(decision.action_id)
|
||||
card_id = engine.mapper.get_card_id(decision.action_id)
|
||||
reward = RewardCalculator(engine.config.reward).calculate(snap)
|
||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||
session.context["last_state"] = idx
|
||||
@ -165,7 +165,7 @@ class ChatService:
|
||||
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
||||
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
|
||||
snap = self._snapshot(session, oc)
|
||||
reward = RewardCalculator(engine.config.reward).calculate(snap)
|
||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||
res.reward_total = reward.total
|
||||
last_state = session.context.get("last_state")
|
||||
last_action = session.context.get("last_action")
|
||||
|
||||
@ -59,7 +59,7 @@ class NegotiationService:
|
||||
decision.card_id = engine.mapper.get_card_id(decision.action_id)
|
||||
|
||||
# 4) 보상
|
||||
reward = RewardCalculator(engine.config.reward).calculate(snap)
|
||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||
|
||||
# 5) 학습: Q-learning 온라인 갱신 + touched 셀 영속화
|
||||
updated_q = decision.q_value
|
||||
|
||||
@ -55,8 +55,8 @@ class AcceptanceConfig(BaseModel):
|
||||
|
||||
|
||||
class PriceZoneConfig(BaseModel):
|
||||
"""입력가격 구간 (KT 구매자: anchor=협력사 기준가 ≥ target=목표 매입가).
|
||||
zone0: 제시가 ≤ anchor(우선협상 가능), zone1: > anchor(협상 지속)."""
|
||||
"""입력가격 구간 (KT 구매자: anchor 앵커링가 < target 목표 매입가).
|
||||
zone0: 제시가 ≤ anchor(우선협상 = 즉시 타결), zone1: > anchor(협상 지속)."""
|
||||
|
||||
weights: List[float] = [1.0, 0.5] # at_or_below_anchor, above_anchor
|
||||
descriptions: List[str] = ["at_or_below_anchor", "above_anchor"]
|
||||
@ -88,12 +88,12 @@ class RewardConfig(BaseModel):
|
||||
기본값은 플랫폼 중립값(균등 가중치)이다 — 특정 고객 튜닝값 복제 아님(CLEANROOM.md).
|
||||
"""
|
||||
|
||||
beta: float = 0.2
|
||||
beta: float = 0.2 # 앵커 초과달성 보너스 계수 (명세 v4 식(9): P<anchor 시 1+β·(anchor−P)/anchor)
|
||||
success_reward: float = 1.0
|
||||
ongoing_reward: float = 0.0
|
||||
failure_penalty: float = -0.5
|
||||
penalty_lambda: float = 0.02
|
||||
# 동적 가중치 기본값: 균등 분배(0.2×5). 테넌트가 자사 특성에 맞게 오버라이드.
|
||||
# 동적 가중치 W 의 메타 가중치 (명세 v4 식(12): W_raw = Σ wᵢ·Sᵢ). 균등 기본값, 테넌트 오버라이드.
|
||||
w1: float = 0.2
|
||||
w2: float = 0.2
|
||||
w3: float = 0.2
|
||||
|
||||
@ -39,9 +39,10 @@ async def test_step_tenant_divergence(client):
|
||||
assert dk["state_index"] != di["state_index"]
|
||||
# 응답 형태
|
||||
assert dk["result"]["success"] is True
|
||||
# input 9950: price_reward=(10000-9950)/(10000-9900)=0.5, round3 weight=0.2,
|
||||
# penalty=0.06, end(success)=1.0 → 0.2*0.5+1.0-0.06 = 1.04
|
||||
assert dk["reward"]["total"] == pytest.approx(1.04, abs=1e-6)
|
||||
# input 9950: price_reward=(10000-9950)/(10000-9900)=0.5,
|
||||
# W=Σ0.2·Sᵢ=0.2×(0.6+0.3+0.5+1.0+0.5)=0.58 (revenue mid, dist A, single, accept high, zone1),
|
||||
# penalty=0.02×3=0.06, end(success)=1.0 → 0.58×0.5 + 0.42×1.0 − 0.06 = 0.65
|
||||
assert dk["reward"]["total"] == pytest.approx(0.65, abs=1e-6)
|
||||
assert dk["logged"] is False # log=false
|
||||
|
||||
|
||||
|
||||
@ -110,8 +110,10 @@ def test_price_zone_and_partner_buckets():
|
||||
|
||||
def test_reward_deterministic_and_config_driven():
|
||||
snap = _snapshot(outcome=NegotiationOutcome.FAILURE, round_number=2)
|
||||
kt_rc = RewardCalculator(_cfg("ktcommerce").reward) # failure_penalty -0.5
|
||||
imk_rc = RewardCalculator(_cfg("imarketkorea").reward) # failure_penalty -0.7
|
||||
kt = _cfg("ktcommerce")
|
||||
imk = _cfg("imarketkorea")
|
||||
kt_rc = RewardCalculator(kt.reward, kt.state) # failure_penalty -0.5
|
||||
imk_rc = RewardCalculator(imk.reward, imk.state) # failure_penalty -0.7
|
||||
|
||||
r1 = kt_rc.calculate(snap)
|
||||
r2 = kt_rc.calculate(snap)
|
||||
|
||||
@ -78,7 +78,7 @@ async def run(tenant_id: str, use_db: bool, interactive: bool):
|
||||
return
|
||||
registry = TenantEngineRegistry(loader=loader)
|
||||
engine = await registry.get_engine(tenant_id)
|
||||
reward_calc = RewardCalculator(engine.config.reward)
|
||||
reward_calc = RewardCalculator(engine.config.reward, engine.config.state)
|
||||
repo = LearningRepository(engine.company_id)
|
||||
episode = EpisodeState()
|
||||
session_id = uuid.uuid4()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user