[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:
hbyang 2026-07-02 14:10:44 +09:00
parent 43ac308a5e
commit be1b4b968f
9 changed files with 57 additions and 33 deletions

View File

@ -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 가 카드로 앵커가 이하까지 끌어내린다.

View File

@ -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
return _clip((snapshot.target_price - snapshot.input_price) / band, 0.0, 1.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:
@ -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,

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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()