o2o-negosium-original/agent/tools/compare_qtable_vs_dqn.py
jwkim d4acdd0ac5 [feat] agent: 카드 선택 DQN 서빙 전환 (action-as-feature)
- feature_dqn_policy: ScoreNet(상태+카드특징 → 점수) 학습 정책 (replay+타깃넷)
- feature_builder: 이산화 없는 연속 상태 벡터(9) + 테넌트 성향 벡터(5)
- dqn_store: numpy 전용 서빙(컨테이너 PyTorch 불필요), DQN_SERVING 플래그,
  미지원 테넌트는 Q-table 자동 폴백
- 파이프라인: build_card_embeddings -> train_feature_dqn -> export_dqn_serving(npz)
- retrain_from_logs: 실로그 재학습 + OPE(SNIPS) 게이트, 통과 시에만 번들 교체(.prev 백업)
- probe_serving_dqn / compare_qtable_vs_dqn: 배포 전 행동 점검 도구

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:56:24 +09:00

252 lines
12 KiB
Python
Raw Permalink 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.

"""기존 Q-Table(UCB) vs action-as-feature DQN 공정 비교 — 고객사 성향 조건화 환경 (최종).
같은 환경(FeatureBuyer 2축 + 협력사·고객사성향 랜덤)에서 동일 에피소드로 학습·평가.
- Q-Table: 이산 state 162칸 + 카드=슬롯. 성향(고객사) 입력 자체가 불가능 → 평균 성향에 수렴
- DQN : 연속 상태 + 성향 벡터 + 카드 특징(임베딩+전략/톤 one-hot)
평가 4종:
① 학습 카드 9장 — 평균보상(진짜 목적함수) + top3 적중(MC 정답 기준)
② zero-shot 11장 — 안 본 카드 2장 포함
③ 새 카드 첫 턴 사용률 — 구조적 차이
④ 성향 극단 테스트 — 같은 협력사, 성향만 바꿨을 때 카드를 바꾸는가
실행: APP_ENV=local python -m tools.compare_qtable_vs_dqn
"""
import random
import numpy as np
import torch
from eval_harness.buyer import Scenario
from eval_harness.feature_buyer import FeatureBuyer, SupplierProfile, sample_supplier
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
from negotiation.policies.qtable_policy import UCBQTablePolicy
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
from negotiation.qtable.domain.model.q_table import QTable
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome
from negotiation.qtable.domain.service.feature_builder import (
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features)
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
from negotiation.qtable.domain.service.state_calculator import state_index
from tenancy.config_loader import TenantConfigLoader
from tools.train_feature_dqn import (
ANCHOR, HOLDOUT, MAX_TURNS, TARGET, load_cards, make_snapshot, pref_config, sample_tenant_pref)
# ---- 정책 어댑터 ------------------------------------------------------------------
class DQNAdapter:
name = "feature_dqn"
def __init__(self, policy, feat):
self.p, self.feat = policy, feat
def _sf(self, snap, tf):
return np.concatenate([build_state_features(snap), tf])
def choose(self, snap, tf, avail, greedy):
self.p.greedy = greedy
i, _, _ = self.p.select(self._sf(snap, tf), np.stack([self.feat[c] for c in avail]))
return avail[i]
def learn(self, snap, tf, card, reward, next_snap, next_avail, done):
sf = self._sf(snap, tf)
if done or next_snap is None:
self.p.remember(sf, self.feat[card], reward, None, None, True)
else:
self.p.remember(sf, self.feat[card], reward, self._sf(next_snap, tf),
np.stack([self.feat[c] for c in next_avail]), False)
self.p.train_step()
class QTableAdapter:
"""기존 UCBQTablePolicy. 성향(tf)은 구조상 받을 수 없다 — 이산 state 162칸에 그 축이 없음."""
name = "qtable_ucb"
def __init__(self, all_numbers, state_cfg, lr=0.1, gamma=0.95):
self.numbers = list(all_numbers)
self.a_of = {n: i for i, n in enumerate(self.numbers)}
self.state_cfg = state_cfg
self.qt = QTable(162, len(self.numbers), learning_rate=lr, discount_factor=gamma)
self.pol = UCBQTablePolicy(self.qt)
def choose(self, snap, tf, avail, greedy):
idx = state_index(snap, self.state_cfg)
if greedy:
q = self.qt.row(idx)
return max(avail, key=lambda c: q[self.a_of[c]])
mask = np.zeros(len(self.numbers), dtype=bool)
for c in avail:
mask[self.a_of[c]] = True
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=len(self.numbers),
episode=EpisodeState(), available_mask=mask)
return self.numbers[self.pol.select(ctx).action_id]
def learn(self, snap, tf, card, reward, next_snap, next_avail, done):
idx = state_index(snap, self.state_cfg)
nidx = state_index(next_snap, self.state_cfg) if (next_snap is not None and not done) else None
self.pol.update(Transition(state_index=idx, action_id=self.a_of[card], reward=reward,
next_state_index=nidx, done=done))
class RandomAdapter:
name = "random"
def __init__(self, seed=0):
self.rng = np.random.default_rng(seed)
def choose(self, snap, tf, avail, greedy):
return avail[self.rng.integers(len(avail))]
def learn(self, *a, **k):
pass
# ---- 공용 에피소드 -----------------------------------------------------------------
def run_episode(adapter, sup, tf, pool, strat, rc, seed, learn=True, greedy=False, forced_first=None):
buyer = FeatureBuyer(sup, strat, seed=seed, max_turns=MAX_TURNS)
scenario = Scenario(anchor_price=ANCHOR, target_price=TARGET, revenue_amount=sup.revenue_amount,
distribution_code=sup.distribution_code, partner_count=sup.partner_count)
price0 = TARGET * 1.15
price, used, total_r, first_card = price0, set(), 0.0, None
for turn in range(1, MAX_TURNS + 1):
acceptance = max(0.0, (price0 - price) / price0)
snap = make_snapshot(sup, price, turn, acceptance)
avail = [c for c in pool if c not in used] or list(pool)
if turn == 1 and forced_first is not None:
card = forced_first
else:
card = adapter.choose(snap, tf, avail, greedy)
used.add(card)
if first_card is None:
first_card = card
resp = buyer.respond(card, scenario, turn, price)
price = resp.new_price
done = resp.accept or price <= ANCHOR or turn >= MAX_TURNS
success = resp.accept or price <= ANCHOR
outcome = (NegotiationOutcome.SUCCESS if success
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
# 채점은 최종 결과 시점만 (중간 턴 0 → γ 부트스트랩으로 전파).
# 진행 중 보상을 누적하면 '질질 끄는 전략'이 부당하게 유리해지는 인공물이 생긴다.
r = rc.calculate(make_snapshot(sup, price, turn, acceptance, outcome)).total if done else 0.0
total_r += r
if learn:
if done:
adapter.learn(snap, tf, card, r, None, None, True)
else:
acc2 = max(0.0, (price0 - price) / price0)
nsnap = make_snapshot(sup, price, turn + 1, acc2)
navail = [c for c in pool if c not in used] or list(pool)
adapter.learn(snap, tf, card, r, nsnap, navail, False)
if done:
return total_r, success, price, first_card
return total_r, False, price, first_card
# ---- MC 정답 랭킹: 이 (협력사, 성향)에서 진짜 좋은 첫 카드 top-k ---------------------
_rand = RandomAdapter(seed=1)
def rank_cards_mc(sup, tf, pool, strat, rc, seed, sims=6, k=3):
means = {}
for c in pool:
rs = [run_episode(_rand, sup, tf, pool, strat, rc, seed=seed + 17 * s,
learn=False, greedy=False, forced_first=c)[0] for s in range(sims)]
means[c] = np.mean(rs)
return sorted(means, key=lambda c: -means[c])[:k]
# ---- 학습/평가 ---------------------------------------------------------------------
def train(adapter, pool, strat, base_reward, state_cfg, episodes, seed):
rng = np.random.default_rng(seed)
for ep in range(1, episodes + 1):
sup = sample_supplier(rng)
rcfg, tf = sample_tenant_pref(rng, base_reward)
rc = RewardCalculator(rcfg, state_cfg)
run_episode(adapter, sup, tf, pool, strat, rc, seed=seed * 100 + ep, learn=True)
def evaluate(adapter, pool, strat, base_reward, state_cfg, n=300, seed0=777, label=""):
from negotiation.qtable.domain.service.feature_builder import build_tenant_features
rng = np.random.default_rng(seed0)
rewards, succ, ratios, hits, holdout_first = [], 0, [], 0, 0
for i in range(n):
sup = sample_supplier(rng)
rcfg, tf = sample_tenant_pref(rng, base_reward)
rc = RewardCalculator(rcfg, state_cfg)
good = rank_cards_mc(sup, tf, pool, strat, rc, seed=seed0 * 7 + i)
r, ok, price, first = run_episode(adapter, sup, tf, pool, strat, rc,
seed=seed0 * 1000 + i, learn=False, greedy=True)
rewards.append(r); succ += ok; ratios.append(price / TARGET)
hits += (first in good); holdout_first += (first in HOLDOUT)
m, ci = float(np.mean(rewards)), float(1.96 * np.std(rewards) / np.sqrt(n))
print(f"{label:<14} mean_rwd={m:.4f} ±{ci:.4f} success={succ/n:.3f} "
f"settled/tgt={np.mean(ratios):.3f} top3_hit={hits/n:.3f} 새카드첫턴={holdout_first/n:.3f}")
def pref_behavior_test(adapters, pool, strat, base_reward, state_cfg):
"""④ 같은 협력사, 성향만 바꿨을 때 카드를 바꾸는가 (greedy).
첫 턴은 '일단 깎기'가 공통 정답이라 성향 차이가 잘 안 드러난다.
→ 협상 중반(가격이 이미 target 근처, 3턴째) 상태를 함께 프로브: 여기서
성사중시는 '마무리(수락 잘 되는) 카드', 가격중시는 '더 깎는 카드'가 갈려야 한다.
"""
from negotiation.qtable.domain.service.feature_builder import build_tenant_features
sups = [SupplierProfile(5_000_000, 3, "A"), # 소형·경쟁多
SupplierProfile(200_000_000, 1, "A")] # 대형·단독
probes = [("첫턴", TARGET * 1.15, 1, 0.0),
("중반(3턴,가격↓)", TARGET * 1.02, 3, 0.11)]
for pr_name, price, turn, acc in probes:
print(f"\n ── 프로브: {pr_name} (price={price:.0f}) ──")
print(f" {'협력사':<13} {'성향':<9} " + " ".join(f"{a.name:<15}" for a in adapters))
for sup in sups:
row = {}
for p, pname in [(0.05, "성사중시"), (0.95, "가격중시")]:
rcfg = pref_config(base_reward, p)
tf = build_tenant_features(rcfg)
picks = []
for a in adapters:
snap = make_snapshot(sup, price, turn, acc)
picks.append(a.choose(snap, tf, pool, True))
seg = f"{sup.segment[0]}·{sup.segment[1]}"
print(f" {seg:<13} {pname:<9} " + " ".join(f"{c}(전략{strat[c]})".ljust(15) for c in picks))
def main(episodes=10000, seed=42):
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
numbers, feat, strat = load_cards()
train_pool = [c for c in numbers if c not in HOLDOUT]
tcfg = TenantConfigLoader().load("ktcommerce")
card_dim = feat[numbers[0]].shape[0]
print(f"환경: 2축 FeatureBuyer + 성향 랜덤 · 학습 {episodes}ep · 카드특징 {card_dim}차원 "
f"(임베딩384+전략4+톤4) · 학습 {len(train_pool)}장 / 홀드아웃 {HOLDOUT}")
qt = QTableAdapter(numbers, tcfg.state)
dqn = DQNAdapter(FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
card_dim=card_dim, eps_decay=4000), feat)
print("\n[학습] qtable_ucb ...")
train(qt, train_pool, strat, tcfg.reward, tcfg.state, episodes, seed)
print("[학습] feature_dqn ...")
train(dqn, train_pool, strat, tcfg.reward, tcfg.state, episodes, seed)
print("\n=== ① 학습 카드 9장 풀 ===")
evaluate(RandomAdapter(seed), train_pool, strat, tcfg.reward, tcfg.state, label="random")
evaluate(qt, train_pool, strat, tcfg.reward, tcfg.state, label="qtable_ucb")
evaluate(dqn, train_pool, strat, tcfg.reward, tcfg.state, label="feature_dqn")
print("\n=== ② zero-shot 11장 풀 (안 본 카드 2장 포함) ===")
evaluate(RandomAdapter(seed), numbers, strat, tcfg.reward, tcfg.state, label="random")
evaluate(qt, numbers, strat, tcfg.reward, tcfg.state, label="qtable_ucb")
evaluate(dqn, numbers, strat, tcfg.reward, tcfg.state, label="feature_dqn")
print("\n=== ④ 성향 극단 테스트 — 같은 협력사, 성향만 바꾸면 카드를 바꾸는가 (11장 풀) ===")
pref_behavior_test([qt, dqn], numbers, strat, tcfg.reward, tcfg.state)
if __name__ == "__main__":
main()