"""train_full_autonomy — 행동 룰 0개, 완전 자율 협상 에이전트 (v2 시뮬 프로토타입). 기존 시스템의 룰(앵커 이하 강제타결 / 3라운드 강제결렬 / 와일드카드 존 / 카드 카탈로그)을 전부 제거하고, 모든 결정을 에이전트 행동으로 이관한다: 행동 공간 (action-as-feature, ScoreNet 이 후보 열거 채점): ACCEPT 현재 제시가로 타결 ← '앵커 이하 강제타결' 룰 대체 WALK 협상 결렬 선언 ← '3라운드 강제결렬' 룰 대체 COUNTER(C) "C원이면 수락" 역제안 ← '와일드카드 1%' 룰 대체 (금액도 학습) PRESS(strategy) 설득 압박(카드의 일반화) ← 카드 카탈로그 대체 (전략만 남음) 룰이 사라진 자리는 보상이 채운다(유일한 스펙): R = W×R_price + (1−W)×R_end − λ×round (기존 RewardCalculator 그대로) 협상이 끝나는 길: 에이전트의 ACCEPT/WALK, 협력사의 COUNTER 수락, 협력사의 인내심 소진(이탈). 마지막 것은 시스템 룰이 아니라 상대방 특성이다. 베이스라인 = 현행 룰 시스템을 같은 환경에서 재현(앵커타결/1%클로징/3라운드결렬 + 압박). 실행: APP_ENV=local PYTHONUTF8=1 python -m tools.train_full_autonomy """ import os import random from typing import Optional, Tuple import numpy as np import torch from eval_harness.feature_buyer import AFFINITY, STRATEGY_PROFILE, SupplierProfile, sample_supplier from negotiation.policies.autonomy_actions import ( ACTION_DIM, ACTIONS, COUNTER_GRID, EXTRA_STATE_DIM, Action, extra_state, internet_gap_feat, settle_norm as extra_settle) from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot from negotiation.qtable.domain.service.feature_builder import ( STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features) from negotiation.qtable.domain.service.reward_calculator import RewardCalculator from tenancy.config_loader import TenantConfigLoader from tools.train_feature_dqn import pref_config, sample_tenant_pref _HERE = os.path.dirname(os.path.abspath(__file__)) CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt") TARGET = 10000.0 # 앵커율(v3.1): 실운영 기하 정합 — 앵커가 = 목표가×(1−a), a ∈ [0.8%, 6%] 를 에피소드마다 샘플링. # (기존 고정 20% 폭은 실제(≈1%)와 지형이 달라, 실서비스에서 압박/역제안 밸런스가 어긋났다.) ANCHOR_RATE_RANGE = (0.008, 0.06) # 행동 공간(Action/ACTIONS/COUNTER_GRID/ACTION_DIM)은 negotiation.policies.autonomy_actions 공유 # — 서빙(autonomy_store, numpy 전용)과 학습이 같은 인코딩을 쓴다. # ---- 협력사 모델 (상대 반응: 역제안 수락/재제안 포함) --------------------------------- class AutonomousBuyer: """FeatureBuyer 확장: 역제안(C)에 반응한다. 이탈은 '인내심' — 시스템 룰이 아닌 상대 특성.""" def __init__(self, sup: SupplierProfile, seed: int): self.sup = sup self.rng = np.random.default_rng(seed) # 기질 t ∈ [0,1]: 0=터프(하한 높고 안 물러섬) ↔ 1=수월. 관측 가능한 이력·최저가가 # 이 숨은 기질과 상관되게 생성된다 → 에이전트가 이력/최저가 특징을 읽을 '이유'가 생긴다. # 하한은 '우리 앵커'가 아니라 협력사 사정(≈목표가 기준)으로 정해진다(v3.1) — # 하한 > 목표가(≈35%)면 애초에 성사 불가능한 협상이고, 그걸 빨리 알아채고 끊는 것도 실력이다. t = float(self.rng.uniform(0.0, 1.0)) self.floor = TARGET * float(np.clip(1.12 - 0.24 * t + self.rng.normal(0, 0.02), 0.85, 1.18)) self.patience = int(self.rng.integers(4, 9)) + (1 if t > 0.7 else 0) # 첫 제시가: 목표가의 105~150% — 실운영(기존 공급가가 목표가를 26%+ 상회) 분포를 덮는다. # 좁게(110~125%) 학습하면 큰 갭 상황에서 정책이 분포 밖 일반화(대형컷 역제안)를 한다. self.price = TARGET * float(self.rng.uniform(1.05, 1.50)) # 하한가가 첫 제시가보다 높을 수 없다(자기 하한 밑으로 부르고 시작하는 판매자는 없음). # 이 보정이 없으면 on_press 의 max(floor,·)가 가격을 '역주행'시키는 비현실이 생긴다. self.floor = min(self.floor, self.price * 0.98) self._last_c: Optional[float] = None # 직전 역제안 (같은 숫자 반복 짜증 모델링) # ---- 관측 가능 부가정보 (v3 특징 소스 — 기질과 상관, 노이즈 있음) ---- self.hist_n = int(self.rng.integers(0, 6)) # 과거 협상 횟수 (0=신규) if self.hist_n: self.hist_success = float(np.clip(0.25 + 0.6 * t + self.rng.normal(0, 0.10), 0.0, 1.0)) self.hist_settle_ratio = float(np.clip(1.18 - 0.28 * t + self.rng.normal(0, 0.04), 0.80, 1.30)) else: self.hist_success = self.hist_settle_ratio = None # 인터넷최저가: 숨은 하한가의 노이즈 관측치. 60% 확률로만 수집돼 있음(현실: 미수집 흔함). self.internet_lowest = (self.floor * float(self.rng.uniform(0.98, 1.08)) if self.rng.random() < 0.6 else None) def _powers(self, strategy: int) -> Tuple[float, float]: conc, acc = STRATEGY_PROFILE.get(strategy, (0.5, 0.5)) m = AFFINITY[self.sup.segment].get(strategy, 0.5) scale = 0.35 + 0.85 * m return conc * scale, acc * scale def on_press(self, strategy: int, turn: int) -> Tuple[bool, float]: """(이탈여부, 새 제시가). 압박이 안 먹히는 세그먼트면 이탈 위험이 실재한다.""" c_pow, a_pow = self._powers(strategy) walk_p = 0.04 + 0.30 * (1.0 - a_pow) * (turn / self.patience) if self.rng.random() < walk_p: return True, self.price concession = (self.price - self.floor) * (0.10 + 0.55 * c_pow) self.price = max(self.floor, self.price - concession) return False, self.price def on_counter(self, c: float, strategy: int, turn: int) -> Tuple[str, float]: """역제안 C 반응: 'accept'(C로 타결) | 'walk' | 'counter'(새 제시가). 현실화(v2): 현 제시가 대비 인하 요구폭(cut)이 클수록 수락률이 급감하고 이탈 위험이 커진다 — 초기 버전에서 에이전트가 't1 원샷 로우볼'로 시뮬 허점을 착취하던 것을 막는다. 압박으로 가격을 충분히 끌어내린 뒤 작은 컷으로 클로징해야 통하는 구조. """ _, a_pow = self._powers(strategy or 3) cut = max(0.0, (self.price - c) / max(self.price, 1.0)) # 인하 요구폭 (현 제시가 대비) prev_c = self._last_c repeated = prev_c is not None and abs(c - prev_c) < 1e-6 # 같은 숫자 반복 self._last_c = c # 양보 상호성(v3.3): 직전 제안보다 올려 부르면(성의 있는 양보) 호의적으로 반응한다. # 이 신호가 있어야 '상대가 내리면 우리도 조금 올리는' tit-for-tat 이 학습으로 나온다. warm = 0.0 if prev_c is not None and c > prev_c + 1e-9: warm = float(np.clip((c - prev_c) / max(self.price - self.floor, 1.0), 0.0, 0.35)) if c >= self.floor: margin = (c - self.floor) / max(self.floor, 1.0) p_acc = float(np.clip(0.20 + 0.9 * margin / 0.08, 0.0, 0.95)) * (0.75 + 0.35 * a_pow) p_acc *= float(np.clip(1.0 - (cut - 0.05) / 0.20, 0.0, 1.0)) # 컷 5% 초과부터 반발, 25%면 수락 0 if repeated: p_acc *= 0.25 # 이미 거절한 숫자를 또 내밀면 설득력 급감 p_acc *= 1.0 + warm if self.rng.random() < min(p_acc, 0.97): return "accept", c # 모욕적 요구(하한 미달·과도한 원샷 컷·앵무새 반복) → 이탈 위험 low = max(0.0, (self.floor - c) / max(self.floor, 1.0)) p_walk = min(0.5, 2.0 * low) + 0.35 * max(0.0, cut - 0.20) / 0.20 + (0.15 if repeated else 0.0) if self.rng.random() < min(p_walk * (1.0 - warm), 0.7): return "walk", self.price self.price = max(self.floor, c + (self.price - c) * float(self.rng.uniform(0.30, 0.60) + warm)) return "counter", self.price # ---- 에피소드 실행 (룰 없음 — 종료는 행동 또는 상대 특성으로만) ------------------------ def make_snapshot(sup, price, turn, p0, anchor, outcome=NegotiationOutcome.ONGOING): return NegotiationSnapshot( revenue_amount=sup.revenue_amount, distribution_code=sup.distribution_code, partner_count=sup.partner_count, acceptance_ratio=max(0.0, (p0 - price) / p0), input_price=price, anchor_price=anchor, target_price=TARGET, round_number=turn, outcome=outcome) MIN_PRESS = int(os.getenv("AUTONOMY_MIN_PRESS", "2")) # 역제시 해금에 필요한 최소 설득 횟수 def available_actions(price: float, last_counter_q: Optional[float] = None, counter_locked: bool = False) -> list: """행동 봉투 (serving autonomy_store 와 동일해야 한다): ① 목표가 초과 제시가는 '수락' 제외 — 매입 승인 범위(목표가 초과 수락 착취 방지) ② 직전 역제안 미만 금액의 역제안 제외 — 단조 양보 원칙(제안 철회 금지; 양보 '속도'는 정책이 배우고, 후퇴 '금지'만 구조로 보장) ③ counter_locked: 설득 MIN_PRESS 회 전에는 역제시 잠금 — 옛 제품 의미론 (일반 카드=설득, 역제시=와일드카드 성격의 마무리 수단) 복원 ④ 마무리 국면(제시가 ≤ 목표가×1.005): 압박 제외 — 푼돈 차이에서 재검토 요청 반복 방지 ⑤ 첫 역제안은 앵커 이하(q ≤ 0)만 — 낮게 개시해 사다리를 다 쓰며 올라간다""" near_target = price <= TARGET * 1.005 return [a for a in ACTIONS if not (a.kind == "accept" and price > TARGET) and not (a.kind == "counter" and counter_locked and not near_target) and not (a.kind == "walk" and counter_locked and not near_target) and not (a.kind == "press" and near_target) and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9) and not (a.kind == "counter" and last_counter_q is not None and a.counter_q < last_counter_q - 1e-9)] def action_feats(price: float, anchor: float, last_counter_q: Optional[float] = None, counter_locked: bool = False): """현 제시가 기준 (가용 행동 리스트, 특징 [K, ACTION_DIM]). counter 는 컷폭 포함.""" span = max(TARGET - anchor, 1.0) pos = (price - anchor) / span acts = available_actions(price, last_counter_q, counter_locked) rows = [] for a in acts: cut = 0.0 if a.kind == "counter": c = anchor + a.counter_q * span cut = max(0.0, (price - c) / max(price, 1.0)) rows.append(a.feat(pos, cut)) return acts, np.stack(rows) def run_episode(policy_fn, sup, rc: RewardCalculator, tf: np.ndarray, seed: int, learner: Optional[FeatureDQNPolicy] = None, trace: Optional[list] = None): """policy_fn(state_feat, price_pos) → Action. learner 지정 시 replay 저장+학습.""" buyer = AutonomousBuyer(sup, seed) p0 = buyer.price env_rng = np.random.default_rng(seed + 7) # 앵커율 샘플링(v3.1): 실운영처럼 앵커가 목표가 바로 아래(0.8~6%) — 좁은 스팬 지형에서 학습. anchor = TARGET * (1.0 - float(env_rng.uniform(*ANCHOR_RATE_RANGE))) span = max(TARGET - anchor, 1.0) turn, settled, walked = 0, None, False last_kind, last_q = "", 0.0 # 직전 역제안 기억 (같은 숫자 반복 방지의 학습 근거) press_n = 0 # 설득 횟수 — 역제시 해금(MIN_PRESS) 카운터 # 견적 마감(환경 사실): 마감 도달 시 협상은 미타결 종료된다 — 룰이 아니라 세상의 시계. deadline_turns = int(env_rng.integers(3, 11)) # 관측성 마스크(v3.5): 실서빙은 마감·이력·최저가가 '없는' 세션이 흔하고 로더가 중립값 # (0.5/0)을 대입한다. 시뮬이 항상 다 아는 세계만 학습하면 그 중립 상태가 분포 밖이 된다 # — v3.4 가 라이브 소액 지형에서 첫 턴 결렬로 퇴화한 원인 추정. 세계(마감 종료·상대 특성)는 # 그대로 두고 관측만 가린다: 마감은 40% 미관측(0.5 고정), 15% 는 전부 미상(신규 견적의 전형). deadline_known = env_rng.random() < 0.6 blind = env_rng.random() < 0.15 if blind: deadline_known = False # 협력사 이력·최저가 특징 (에피소드 내 불변) known_hist = buyer.hist_n and not blind fixed_extra = dict( hist_n=min(buyer.hist_n, 5) / 5.0 if not blind else 0.0, hist_success=buyer.hist_success if known_hist else 0.5, hist_settle=extra_settle(buyer.hist_settle_ratio) if known_hist else 0.5, internet_gap=internet_gap_feat(buyer.internet_lowest or 0.0, anchor) if not blind else 0.0, ) pending = None # (state_feat, action_feat) — 최종 결과 시점만 채점, 중간 r=0 while True: turn += 1 price = buyer.price deadline_remain = (max(0.0, (deadline_turns - turn + 1) / deadline_turns) if deadline_known else 0.5) # 미관측 → 서빙 로더와 동일한 중립값 sf = np.concatenate([build_state_features(make_snapshot(sup, price, turn, p0, anchor)), tf, extra_state(last_kind, last_q, deadline=deadline_remain, **fixed_extra)]) lcq = last_q if last_kind == "counter" else None locked = lcq is None and press_n < MIN_PRESS act = policy_fn(sf, price, anchor, lcq, locked) if trace is not None: trace.append((turn, int(price), act)) if act.kind == "accept": settled = price elif act.kind == "walk": walked = True elif act.kind == "counter": c = anchor + act.counter_q * span resp, val = buyer.on_counter(c, act.strategy, turn) last_kind, last_q = "counter", act.counter_q # 역제안 기억 갱신 if resp == "accept": settled = c elif resp == "walk": walked = True else: # press press_n += 1 left, _ = buyer.on_press(act.strategy, turn) walked = walked or left if not settled and not walked and turn >= buyer.patience: walked = True # 인내심 소진(상대 특성) — 시스템 룰 아님 if not settled and not walked and turn >= deadline_turns: walked = True # 견적 마감 도달(환경 사실) — 미타결 종료 done = settled is not None or walked final_price = settled if settled is not None else buyer.price # 성사 보너스는 목표가 이하 타결에만 — v3.1 이 '비싸게라도 성사'로 착취한 보상 구멍의 # 원인 차단(봉투 ① 의 마스크와 이중 방어: 유인 자체를 올바르게). 초과 타결 = 결렬 취급. outcome = (NegotiationOutcome.SUCCESS if settled is not None and settled <= TARGET else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING) r = rc.calculate(make_snapshot(sup, final_price, turn, p0, anchor, outcome)).total if done else 0.0 if learner is not None: pos = (price - anchor) / span cut = 0.0 if act.kind == "counter": cut = max(0.0, (price - (anchor + act.counter_q * span)) / max(price, 1.0)) af = act.feat(pos, cut) if pending: nxt_lcq = last_q if last_kind == "counter" else None learner.remember(*pending, 0.0, sf, action_feats(price, anchor, nxt_lcq, nxt_lcq is None and press_n < MIN_PRESS)[1], False) pending = (sf, af) if done: learner.remember(sf, af, r, None, None, True) learner.train_step() if done: return settled, turn, r # ---- 정책들 ------------------------------------------------------------------ def dqn_policy(policy: FeatureDQNPolicy): def f(sf, price, anchor, last_counter_q=None, counter_locked=False): acts, feats = action_feats(price, anchor, last_counter_q, counter_locked) i, _, _ = policy.select(sf, feats) return acts[i] return f class RuleBaseline: """현행 시스템 룰 재현: 앵커 이하 수락 / 존내 1% 클로징 / 3회 압박 후 결렬.""" def __init__(self): self.presses, self.closed = 0, False def __call__(self, sf, price, anchor, last_counter_q=None, counter_locked=False) -> Action: span = max(TARGET - anchor, 1.0) if price <= anchor: return Action("accept") if price <= anchor * 1.02 and not self.closed: self.closed = True return Action("counter", (price * 0.99 - anchor) / span, 3) if self.presses < 3: self.presses += 1 return Action("press", 0.0, 3) return Action("walk") # ---- 학습/평가 ---------------------------------------------------------------- def evaluate(name, make_policy_fn, base_cfg, tcfg_state, episodes=3000, seed0=777): rc = RewardCalculator(pref_config(base_cfg, 0.5), tcfg_state) tf = build_tenant_features(pref_config(base_cfg, 0.5)) rng = np.random.default_rng(seed0) rewards, settles, rounds = [], [], [] for i in range(episodes): sup = sample_supplier(rng) settled, turn, r = run_episode(make_policy_fn(), sup, rc, tf, seed0 * 91 + i) rewards.append(r) rounds.append(turn) if settled is not None: settles.append(settled / TARGET) sr = len(settles) / episodes print(f"{name:<14} 보상 {np.mean(rewards):.4f} ±{np.std(rewards)/np.sqrt(episodes):.4f}" f" 성사율 {sr:.3f} 타결가/목표 {np.mean(settles):.3f} 평균라운드 {np.mean(rounds):.2f}") return dict(reward=float(np.mean(rewards)), success=sr, settle_ratio=float(np.mean(settles)) if settles else None, rounds=float(np.mean(rounds))) def main(episodes=15000, seed=42): random.seed(seed); np.random.seed(seed); torch.manual_seed(seed) tcfg = TenantConfigLoader().load("ktcommerce") policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM, card_dim=ACTION_DIM, eps_decay=5000, gamma=0.97) rng = np.random.default_rng(seed) print(f"=== 완전 자율 학습 {episodes}ep (행동 {len(ACTIONS)}개, 룰 0개) ===") recent = [] for ep in range(1, episodes + 1): sup = sample_supplier(rng) rcfg, tf = sample_tenant_pref(rng, tcfg.reward) rc = RewardCalculator(rcfg, tcfg.state) _, _, r = run_episode(dqn_policy(policy), sup, rc, tf, seed * 131 + ep, learner=policy) recent.append(r) if ep % 3000 == 0: print(f" ep {ep:>6} eps={policy.eps():.3f} 최근3000 평균보상={np.mean(recent[-3000:]):.4f}") policy.save(CKPT_PATH) print("\n=== 평가 3000ep (중립 성향 p=0.5, 동일 협력사 분포) ===") policy.greedy = True evaluate("룰시스템(현행)", lambda: RuleBaseline(), tcfg.reward, tcfg.state) evaluate("완전자율 DQN", lambda: dqn_policy(policy), tcfg.reward, tcfg.state) # 궤적 예시 — 에이전트가 룰 없이 뭘 하는지 눈으로 print("\n=== 궤적 예시 (완전자율) ===") rc = RewardCalculator(pref_config(tcfg.reward, 0.5), tcfg.state) tf = build_tenant_features(pref_config(tcfg.reward, 0.5)) rng2 = np.random.default_rng(7) for k in range(3): sup = sample_supplier(rng2) trace = [] settled, turn, r = run_episode(dqn_policy(policy), sup, rc, tf, 5000 + k, trace=trace) seg = "·".join(sup.segment) print(f"[{seg}] " + " → ".join( f"t{t}:{p:,}원 {a.kind}{'' if a.kind in ('accept', 'walk') else f'({a.counter_q:.2f},전략{a.strategy})' if a.kind == 'counter' else f'(전략{a.strategy})'}" for t, p, a in trace) + f" ⇒ {'타결 ' + format(int(settled), ',') + '원' if settled else '결렬'} (r={r:.3f})") if __name__ == "__main__": main()