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