File size: 12,586 Bytes
48c41b6 58debbc 48c41b6 9361a1c 48c41b6 74d4d2b 58debbc 74d4d2b 9361a1c 74d4d2b 9a4b1fd 58debbc 74d4d2b 9a4b1fd 74d4d2b 48c41b6 58debbc 48c41b6 74d4d2b 48c41b6 74d4d2b 48c41b6 74d4d2b 9a4b1fd 74d4d2b 9a4b1fd 48c41b6 58debbc 48c41b6 9a4b1fd 48c41b6 99d7e3b 9361a1c 0eeced9 9361a1c 99d7e3b 58debbc 99d7e3b 45a9700 58debbc 45a9700 99d7e3b 45a9700 99d7e3b fd1a4da 45a9700 48c41b6 58debbc 48c41b6 74d4d2b 45a9700 48c41b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | from __future__ import annotations
"""
Intent ์ถ๋ก ์ด์ (reasoning) ์ถ์ถ โ ์๋๋ฆฌ์ค ๋ฌด๊ด.
- Rule : L2 rule ์ ์ธํ spec์ top-level ํญ(terms)๋ณ ๊ธฐ์ฌ๋๋ฅผ ๋ถํด.
- Model : sklearn(StandardScaler+LogisticRegression) ์ ํ์ฑ์ ์ด์ฉํด
feature๋ณ ๊ธฐ์ฌ๋ = coef ร ํ์คํ๊ฐ ์ผ๋ก ๋ถํด (models.sklearn_model.explain).
- ํ๋ : rank_change>0๋ฉด "์ต๊ทผ ํ๋์ผ๋ก ์์น" ๋
ธํธ.
๋ฐํ ํ์(intent๋ณ):
{"type": "Rule"|"Model",
"factors": [{"label": str, "contribution": float, "direction": "up"|"down"}, ...]} # |๊ธฐ์ฌ| ์์
"""
from typing import Any
from core.engines import config
from core.engines.formula import eval_formula
def _node_label(node: Any) -> str:
"""rule ํญ ๋
ธ๋๋ฅผ ์ฌ๋์ด ์ฝ์ ๋ผ๋ฒจ๋ก ๋ณํํ๋ค.
Args:
node: rule ํญ ๋
ธ๋.
Returns:
๋ผ๋ฒจ ๋ฌธ์์ด.
"""
if isinstance(node, (int, float, bool)):
return "๊ธฐ๋ณธ ์ ์"
if not isinstance(node, dict):
return str(node)[:24]
if "feat" in node:
return str(node["feat"])
if "boost" in node: # ํ๋(์๋์ฐ) feature
return f"ํ๋: {node['boost'].get('feat', '')}"
if "if" in node:
cond = node["if"]
feat = cond.get("feat") or (cond.get("all") or cond.get("any") or [{}])[0].get("feat")
return f"์กฐ๊ฑด: {feat}" if feat else "์กฐ๊ฑด"
if "switch" in node:
c0 = (node["switch"] or [{}])[0].get("if", {})
return f"์กฐ๊ฑด: {c0.get('feat', '')}"
if "clamp" in node and "value" in node:
return _node_label(node["value"])
if "terms" in node: # ์ค์ฒฉ ํญ โ ์ฒซ feature ํญ์ผ๋ก ๋ํ
for t in node["terms"]:
if not isinstance(t, (int, float, bool)):
return _node_label(t)
return "๋ณตํฉ ํญ"
return "ํญ"
def _node_feat(node: Any) -> str | None:
"""rule ํญ์ด ์ฐธ์กฐํ๋ feature ํค๋ฅผ ์ฐพ๋๋ค (ํ์ฌ๊ฐ ์กฐํ์ฉ).
Args:
node: rule ํญ ๋
ธ๋.
Returns:
์ฐธ์กฐ feature ํค. ์์ผ๋ฉด None.
"""
if not isinstance(node, dict):
return None
if "feat" in node:
return node["feat"]
if "boost" in node:
return node["boost"].get("feat")
if "if" in node:
c = node["if"]
return c.get("feat") or (c.get("all") or c.get("any") or [{}])[0].get("feat")
if "switch" in node:
return (node["switch"] or [{}])[0].get("if", {}).get("feat")
if "clamp" in node and "value" in node:
return _node_feat(node["value"])
if "terms" in node:
for t in node["terms"]:
f = _node_feat(t)
if f:
return f
return None
# ์ฝ๋๊ฐ feature โ ์ค์ ์๋ฏธ (์ฝ๋๊ฐ ์๋ ์ค์ ๊ฐ ํ์)
VALUE_LABELS = {
"contract_status": {1: "์ฝ์ ์์", 2: "์ฝ์ ์์ฌ 6๊ฐ์+", 3: "์ฝ์ ๋ง๋ฃ ์๋ฐ"},
"๊ณ ๊ฐ ๋ฑ๊ธ": {"Gold": "๊ณจ๋", "Silver": "์ค๋ฒ", "Bronze": "๋ธ๋ก ์ฆ", "VIP": "VIP", "Green": "๊ทธ๋ฆฐ"},
}
def _fmt_value(feat: str | None, v: Any) -> str | None:
"""feature ํ์ฌ๊ฐ์ ํ์ ๋ฌธ์์ด๋ก ๋ณํํ๋ค.
์ฝ๋๊ฐ(contract_status ๋ฑ)์ ์ค์ ์๋ฏธ๋ก ๋ณํํ๋ค.
Args:
feat: feature ํค. VALUE_LABELS ์กฐํ์ ์ฌ์ฉ๋๋ค.
v: feature ํ์ฌ๊ฐ.
Returns:
ํ์ ๋ฌธ์์ด. v๊ฐ None์ด๋ฉด None.
"""
if v is None:
return None
if feat in VALUE_LABELS:
key = int(v) if isinstance(v, (int, float)) and float(v).is_integer() else v
if key in VALUE_LABELS[feat]:
return VALUE_LABELS[feat][key]
if isinstance(v, bool):
return "์" if v else "์๋์ค"
if isinstance(v, (int, float)):
return f"{v:.2f}".rstrip("0").rstrip(".")
return str(v)
def explain_rule(scenario_id: str, intent_id: str, features: dict, top: int = 3) -> list[dict]:
"""rule spec์ top-level ํญ๋ณ ๊ธฐ์ฌ๋๋ฅผ ๋ถํดํ๋ค.
|๊ธฐ์ฌ| ์์ top๊ฐ๋ฅผ ๋ฐํํ๋ฉฐ, ์์(๊ธฐ๋ณธ ์ ์)๋ ์ ์ธํ๊ณ ํ๊ธ ๋ผ๋ฒจยท์ค์ ๊ฐ์ ํฌํจํ๋ค.
Args:
scenario_id: ์๋๋ฆฌ์ค ID.
intent_id: ๋์ intent ID.
features: ์ถ๋ก ์ ์ฐ์ธ feature ๋งคํ.
top: ๋ฐํํ ์์ ํญ ๊ฐ์.
Returns:
|๊ธฐ์ฌ| ๋ด๋ฆผ์ฐจ์ ์์ top๊ฐ์ factor dict ๋ฆฌ์คํธ. spec์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ.
"""
spec = config.get_rule_spec(scenario_id).get(intent_id)
if spec is None:
return []
terms = spec["terms"] if isinstance(spec, dict) and "terms" in spec else [spec]
out = []
for t in terms:
if isinstance(t, (int, float, bool)): # ์์ ํญ(๊ธฐ๋ณธ ์ ์) ์ ์ธ
continue
val = float(eval_formula(t, features))
if abs(val) < 1e-9:
continue
feat = _node_feat(t)
out.append({"label": _label_ko(_node_label(t)), "contribution": round(val, 4),
"direction": "up" if val >= 0 else "down",
"value": _fmt_value(feat, features.get(feat)) if feat else None})
return sorted(out, key=lambda o: -abs(o["contribution"]))[:top]
def explain_intent(engine, intent_id: str, features: dict, inference_type: str, top: int = 3) -> dict:
"""intent 1๊ฐ์ ์ถ๋ก ์ด์ ๋ฅผ ๋ถํดํ๋ค.
inference_type์ ๋ฐ๋ผ rule/model๋ก ๋ถํดํ๋ฉฐ, ๋ผ๋ฒจ์ ํ๊ธํํ๊ณ ์ฝ๋๊ฐ์ ์ค์ ๊ฐ์ผ๋ก ๋ณํํ๋ค.
Args:
engine: ์๋๋ฆฌ์ค ์์ง.
intent_id: ๋์ intent ID.
features: ์ถ๋ก ์ ์ฐ์ธ feature ๋งคํ.
inference_type: "Model" ๋๋ "Rule".
top: ๋ฐํํ ์์ factor ๊ฐ์.
Returns:
{"type", "factors"} ํค๋ฅผ ๊ฐ์ง reasoning dict.
"""
if inference_type == "Model":
factors = engine.explain_model(intent_id, features, top=top)
for f in factors: # ๋ชจ๋ธ feature๋ช
โ ํ๊ธ, ์ฝ๋๊ฐ โ ์ค์ ๊ฐ
feat = f.get("label", "")
f["value"] = _fmt_value(feat, f.get("value"))
f["label"] = _label_ko(feat)
return {"type": "Model", "factors": factors}
return {"type": "Rule", "factors": explain_rule(engine.scenario_id, intent_id, features, top=top)}
# feature ๋ด๋ถ๋ช
โ ์๋ด์ฌ์ฉ ํ๊ธ ๋ผ๋ฒจ
FEATURE_LABELS = {
"์ดํ ์ํ Score": "์ดํ ์ํ๋", "Churn Risk Index": "์ดํ ์ํ๋",
"์๊ธ ๋ฏผ๊ฐ๋ Index": "์๊ธ ๋ฏผ๊ฐ๋", "๋น์ฉ ๋ถ๋ด๋": "๋น์ฉ ๋ถ๋ด", "์๊ธ์ ์์ ์ก": "์๊ธ์ ๊ธ์ก",
"์ฝ์ ์งํ๋ฅ ": "์ฝ์ ์งํ ์ ๋", "contract_status": "์ฝ์ ์ํ",
"๋ฐ์ดํฐ ์ฌ์ฉ ์ฆ๊ฐ๋ฅ ": "๋ฐ์ดํฐ ์ฌ์ฉ ์ฆ๊ฐ์ธ", "๋ฐ์ดํฐ ์ฌ์ฉ๋ฅ ": "๋ฐ์ดํฐ ์ฌ์ฉ๋",
"์
์
์ ํฉ๋ Score": "์์ ์ํ ์ ํฉ๋", "์ฌ์ฉ ๊ฐ๋ Index": "์ฌ์ฉ ๊ฐ๋",
"๋จ๋ง ๊ต์ฒด ์ํฅ Score": "๋จ๋ง ๊ต์ฒด ์ํฅ", "๋ฉค๋ฒ์ญ ์ฃผ๊ฐ ์ฌ์ฉ ํ์": "๋ฉค๋ฒ์ญ ์ฌ์ฉ ๋น๋",
"๊ณ ๊ฐ ๊ฐ์น Index": "๊ณ ๊ฐ ๊ฐ์น", "๋ฉค๋ฒ์ญ ํ์ฉ๋": "๋ฉค๋ฒ์ญ ํ์ฉ๋", "๊ณ ๊ฐ ๋ฑ๊ธ": "๊ณ ๊ฐ ๋ฑ๊ธ",
"๊ฐ์กฑ ํ์ ์": "๊ฐ์กฑ ํ์ ์", "non_mobile_cost_gap": "๊ฒฐํฉ ๋น์ฉ ๊ฒฉ์ฐจ",
"mnp_benefit_check": "๋ฒํธ์ด๋ ํํ ์กฐํ", "์์ฝ๊ธ ์กฐํ ํ๋": "์์ฝ๊ธ ํ์ด์ง ์กฐํ",
"ํด์ง ํ์ด์ง ์ง์
": "ํด์ง ํ์ด์ง ๋ฐฉ๋ฌธ", "churn_page_view_count": "ํด์ง ๊ด๋ จ ํ์ด์ง ์กฐํ",
"dissatisfaction_factor": "์๋น์ค ๋ถ๋ง ์์ธ", "support_entry_count_5m": "์๋ด ์ง์
",
"quality_action_count": "ํ์ง ์ง๋จ ์คํ", "benefit_explore_count": "ํํ ํ์",
"billing_page_view_count": "์๊ธ ์กฐํ", "product_explore_count": "์ํ ํ์",
"social_contact": "์ฌํ์ ์ ์ด", "weekend_out": "์ฃผ๋ง ์ธ์ถ", "night_phone_usage": "์ผ๊ฐ ์ค๋งํธํฐ ์ฌ์ฉ",
"move_pattern": "ํด๊ทผ ํ ์ด๋", "Isolation Tendency Index": "๊ณ ๋ฆฝ ์ฑํฅ",
"Sleep Disturbance Index": "์๋ฉด ๋ฐฉํด", "Burnout Deep Score": "๋ฒ์์ ์ ๋",
"Recovery Motivation Score": "ํ๋ณต ๋๊ธฐ", "Fatigue Load Index": "ํผ๋ก ๋์ ",
"Digital Escape Score": "๋์งํธ ๋ํผ ์ฑํฅ", "Retention Value Index": "์ ์ง ๊ฐ์น",
"Benefit Engagement Index": "ํํ ์ฐธ์ฌ๋",
# bundle ๋ชจ๋ธ Index/Score
"Bundle Opportunity Index": "๊ฒฐํฉ ๊ธฐํ ์ง์", "Home Service Expansion Index": "ํ ์๋น์ค ํ์ฅ ์ง์",
"Benefit Optimization Index": "ํํ ์ต์ ํ ์ง์", "Benefit Optimization Score": "ํํ ์ต์ ํ ์ ์",
"Service Expansion Score": "์๋น์ค ํ์ฅ ์ ์", "Acquisition Score": "์ ๊ท ํ๋ ์ ์",
"Retention Readiness Index": "์ ์ง ์ค๋น ์ง์", "Retention Value Index": "์ ์ง ๊ฐ์น ์ง์",
"Retention Value Score": "์ ์ง ๊ฐ์น ์ ์", "Retention Score": "์ ์ง ์ ์",
"Churn Defense Score": "์ดํ ๋ฐฉ์ด ์ ์",
# ํ๋/์ด๋ฒคํธ ์๋์ฐ feature (์๋ด์ฌ [์ํฉ]์ ๋ฑ์ฅ)
"churn_action_count_5m": "์ต๊ทผ ํด์ง ๊ด๋ จ ํ๋", "comparison_action_count_5m": "์ต๊ทผ ๋น๊ต ํ๋",
"decision_action_count_5m": "์ต๊ทผ ๊ฐ์
ยท๊ฒฐ์ ํ๋", "entity_focus_ratio_5m": "ํน์ ๋ฉ๋ด ์ง์ค๋",
"WiFi ์ง๋จ ์คํ": "WiFi ์ง๋จ ์คํ", "์๋ ์ธก์ ์คํ": "์๋ ์ธก์ ์คํ",
"์ฅ์ ํ์ด์ง ์ฒด๋ฅ": "์ฅ์ ํ์ด์ง ์ฒด๋ฅ", "ํ ์ธ ํ์ด์ง ์ฒด๋ฅ": "ํ ์ธ ํ์ด์ง ์กฐํ",
"๊ฐ์กฑ ๊ฒฐํฉ ๊ด๋ จ ํ๋": "๊ฐ์กฑ ๊ฒฐํฉ ํ์ด์ง ์กฐํ",
# ํ๋กํ snake_case
"benefit_utilization": "ํํ ํ์ฉ๋", "content_view_mode": "์์ ์๋น ๊ฐ๋",
"family_line_count": "๊ฐ์กฑ ํ์ ์", "household_change": "๊ฐ๊ตฌ ๋ณํ", "offwork_time": "ํด๊ทผ ์๊ฐ",
"overtime_freq": "์ผ๊ทผ ๋น๋", "plan_tier": "์๊ธ์ ๋ฑ๊ธ", "plan_bill_level": "์๊ธ์ ๊ธ์ก๋",
"monthly_bill_level": "์ ์๊ธ ์์ค", "service_coverage_ratio": "์๋น์ค ์ปค๋ฒ๋ฆฌ์ง ๋น์จ",
"tenure_group": "๊ฐ์
๊ธฐ๊ฐ๋", "age_group": "์ฐ๋ น๋", "subscribed_service_count": "๊ฐ์
๋ถ๊ฐ์๋น์ค ์",
}
def _label_ko(raw: str) -> str:
"""factor ๋ผ๋ฒจ์ ์์ฐ์ค๋ฌ์ด ํ๊ธ๋ก ๋ณํํ๋ค.
'์กฐ๊ฑด:/ํ๋:' ์ ๋๋ฅผ ์ ๊ฑฐํ ๋ค ๋งคํํ๋ฉฐ, ๋ฏธ๋ฑ๋ก ๋ผ๋ฒจ์ Index/Score ์ ๋ฏธ์ฌ๋ฅผ ์ ๋ฆฌํ๋ค.
Args:
raw: ์๋ณธ factor ๋ผ๋ฒจ.
Returns:
ํ๊ธํ๋ ๋ผ๋ฒจ.
"""
s = raw.replace("์กฐ๊ฑด: ", "").replace("ํ๋: ", "").strip()
if s in FEATURE_LABELS:
return FEATURE_LABELS[s]
return s.replace(" Index", "").replace(" Score", "").strip() or s
def _situation_text(intent_name: str, r: dict) -> str:
"""์๋ด์ฌ ์ฝ์ [์ํฉ]์ฉ ์ถ๋ก ์ด์ ๋ฌธ์ฅ์ ๋ง๋ ๋ค.
์ด ๊ณ ๊ฐ ํน์ฑ์ผ๋ก intent๊ฐ ์ถ๋ก ๋ ์ด์ ๋ฅผ ์์ฐ์ด ํ ๋ฌธ์ฅ์ผ๋ก ํํํ๋ค.
Args:
intent_name: intent ํ๊ธ๋ช
.
r: reasoning dict (factors ํฌํจ).
Returns:
์์ฐ์ด ํ ๋ฌธ์ฅ.
"""
facts = [f for f in r.get("factors", []) if f.get("label") != "๊ธฐ๋ณธ ์ ์"]
facts = [f for f in facts if f.get("direction") == "up"] or facts # ์๋๋ฅผ ๋์ด์ฌ๋ฆฐ ํน์ฑ ์ฐ์
labels = []
for f in facts[:3]:
lab = _label_ko(f["label"])
if lab and lab not in labels:
labels.append(lab)
if not labels:
head = f"๊ณ ๊ฐ ์๋ต์ ์ข
ํฉํด '{intent_name}' ์๋๊ฐ ์ถ๋ก ๋์์ต๋๋ค"
else:
head = f"์ด ๊ณ ๊ฐ์ {' ยท '.join(labels)} ์ธก๋ฉด์ด ๋๋๋ฌ์ ธ '{intent_name}' ์๋๊ฐ ์ถ๋ก ๋์์ต๋๋ค"
return head + "." # ์์ ๋ณ๋(behavior_note)์ ์๋ด์ฌ [์ํฉ]์์ ์ ์ธ
def attach_reasoning(engine, features: dict, top_items: list[dict], top: int = 3) -> None:
"""์๋น top_items ๊ฐ ํญ๋ชฉ์ reasoning์ ์ฒจ๋ถํ๋ค (in-place).
reasoning.situation_text๋ ์๋ด์ฌ ์ฝ์ [์ํฉ]์ฉ ๋์ ์ถ๋ก ์ด์ ๋ฌธ์ฅ์ด๋ค.
Args:
engine: ์๋๋ฆฌ์ค ์์ง.
features: ์ถ๋ก ์ ์ฐ์ธ ๊ฒฐํฉ feature(batch+pattern+event) ๋งคํ.
top_items: reasoning์ ์ฒจ๋ถํ ์๋น ํญ๋ชฉ ๋ฆฌ์คํธ.
top: ํญ๋ชฉ๋น ๋ถํดํ ์์ factor ๊ฐ์.
"""
for it in top_items:
r = explain_intent(engine, it["intent_id"], features, it.get("inference_type", "Rule"), top=top)
rc = it.get("rank_change", 0)
if rc and rc > 0:
r["behavior_note"] = f"์ต๊ทผ ํ๋์ผ๋ก {rc}์ ์์น"
elif rc and rc < 0:
r["behavior_note"] = f"์ต๊ทผ ํ๋์ผ๋ก {abs(rc)}์ ํ๋ฝ"
r["situation_text"] = _situation_text(it.get("intent_nm_ko", it["intent_id"]), r)
it["reasoning"] = r
|