| from __future__ import annotations |
| """ |
| Model-based Intent Inference ([2b] ๋ชจ๋) |
| |
| ์๋๋ฆฌ์ค ๋ฌด๊ด sklearn Logistic Regression ์ถ๋ก ๋จธ์ ๋ฌ๋ฆฌ. |
| - ํ์ต ๋ฐ์ดํฐ(training_data)ยทdataset_pathยทmodel_prefix๋ ํธ์ถ์(์๋๋ฆฌ์ค ์์ง)๊ฐ ์ฃผ์
|
| - StandardScaler + LogisticRegression Pipeline |
| - MLflow Registry ๋ฑ๋ก (๋ชจ๋ธ๋ช
: {model_prefix}{intent_id}_sklearn) |
| - seed ๊ณ ์ (42)์ผ๋ก ์ฌํ์ฑ ํ๋ณด |
| """ |
| import json |
| import logging |
| import random |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import mlflow |
| import mlflow.sklearn |
| from sklearn.linear_model import LogisticRegression |
| from sklearn.preprocessing import StandardScaler |
| from sklearn.pipeline import Pipeline |
|
|
| from config import settings |
|
|
| logger = logging.getLogger(__name__) |
| _model_cache: dict[str, Any] = {} |
|
|
|
|
| |
|
|
| def _train_pipeline(X: list, y: list, seed: int = 42, train_params: dict | None = None) -> Pipeline: |
| """StandardScaler + LogisticRegression ํ์ดํ๋ผ์ธ์ ํ์ตํ๋ค. |
| |
| seed๋ฅผ ๊ณ ์ ํ์ฌ ์ฌํ์ฑ์ ํ๋ณดํ๋ค. |
| |
| Args: |
| X: ํน์ง ํ๋ ฌ. |
| y: ๋ ์ด๋ธ ๋ฒกํฐ. |
| seed: ๋์ ์๋ (์ฌํ์ฑ). |
| train_params: ์๋๋ฆฌ์ค config L2.model.train์ ํ์ดํผํ๋ผ๋ฏธํฐ. |
| class_weightยทC๋ฅผ override ๊ฐ๋ฅ (๊ธฐ๋ณธ balancedยทC=1.0). |
| |
| Returns: |
| ํ์ต๋ sklearn Pipeline. |
| """ |
| tp = train_params or {} |
| pipe = Pipeline([ |
| ("scaler", StandardScaler()), |
| ("lr", LogisticRegression( |
| random_state=seed, |
| max_iter=500, |
| C=tp.get("C", 1.0), |
| class_weight=tp.get("class_weight", "balanced"), |
| )), |
| ]) |
| pipe.fit(np.array(X, dtype=float), np.array(y)) |
| return pipe |
|
|
|
|
| def _extract_from_dataset( |
| intent_id: str, |
| feature_names: list[str], |
| seed: int, |
| dataset_path: Path, |
| neg_pos_ratio: float = 2.0, |
| ) -> tuple[list[list[float]], list[int]] | None: |
| """seed_dataset.json์์ intent_id์ ๋ํ (X, y)๋ฅผ ์ถ์ถํ๋ค. |
| |
| ์์ฑ์ sample["intent_labels"]์ intent_id๊ฐ ์๋ ๊ฒฝ์ฐ(y=1), ์์ฑ์ ๊ทธ ์ธ(y=0)์ด๋ค. |
| ํด๋์ค ๋ถ๊ท ํ ์ฒ๋ฆฌ๋ฅผ ์ํด ์์ฑ์ neg_pos_ratio ร n_pos ๊น์ง๋ง ์ํ๋งํ๋ค. |
| |
| Args: |
| intent_id: ์ถ์ถํ Intent ID. |
| feature_names: feature ๋ฒกํฐ๋ฅผ ๊ตฌ์ฑํ feature ์ด๋ฆ ์์. |
| seed: ์์ฑ ์ํ๋ง์ ์ฌ์ฉํ ๋์ ์๋. |
| dataset_path: seed_dataset.json ๊ฒฝ๋ก. |
| neg_pos_ratio: ์์ฑ ๋๋น ์์ฑ ์ํ ๋น์จ ์ํ. |
| |
| Returns: |
| (X, y) ํํ. ์์ฑยท์์ฑ์ด ๊ฐ๊ฐ 3๊ฑด ๋ฏธ๋ง์ด๊ฑฐ๋ ๋ฐ์ดํฐ์
์ด ์์ผ๋ฉด None. |
| """ |
| if not dataset_path.exists(): |
| return None |
| try: |
| with open(dataset_path, encoding="utf-8") as f: |
| dataset = json.load(f) |
| except Exception as e: |
| logger.warning(f"Failed to load seed_dataset.json: {e}") |
| return None |
|
|
| X_pos, X_neg = [], [] |
| for sample in dataset.get("samples", []): |
| |
| feats = { |
| **sample.get("batch_features", {}), |
| **sample.get("pattern_features", {}), |
| **sample.get("event_features", {}), |
| } |
| x = [float(feats.get(name, 0.0)) for name in feature_names] |
| if intent_id in sample.get("intent_labels", {}): |
| X_pos.append(x) |
| else: |
| X_neg.append(x) |
|
|
| if len(X_pos) < 3 or len(X_neg) < 3: |
| return None |
|
|
| rng = random.Random(seed) |
| n_neg_target = min(len(X_neg), max(int(len(X_pos) * neg_pos_ratio), 10)) |
| X_neg_sampled = rng.sample(X_neg, n_neg_target) if len(X_neg) > n_neg_target else X_neg |
|
|
| X = X_pos + X_neg_sampled |
| y = [1] * len(X_pos) + [0] * len(X_neg_sampled) |
| return X, y |
|
|
|
|
| def train_and_register( |
| intent_id: str, |
| training_data: dict, |
| dataset_path: Path, |
| model_prefix: str, |
| seed: int = 42, |
| train_params: dict | None = None, |
| ) -> Pipeline | None: |
| """Intent์ ํ์ต ๋ฐ์ดํฐ๋ก ๋ชจ๋ธ์ ํ์ตํ๊ณ MLflow์ ๋ฑ๋กํ๋ค. |
| |
| ๋ฐ์ดํฐ ์์ค ์ฐ์ ์์: |
| 1) dataset_path(seed_dataset.json)์ ํ๋ฅด์๋ ์๋ ๋ฐ์ดํฐ์
|
| 2) training_data[intent_id]์ ๋๋ฉ์ธ ์ง์ X, y |
| |
| Args: |
| intent_id: ํ์ตํ Intent ID. |
| training_data: Intent๋ณ ํ์ต ์ ์(featuresยทXยทy ๋ฑ). |
| dataset_path: seed_dataset.json ๊ฒฝ๋ก. |
| model_prefix: ์๋๋ฆฌ์ค๋ณ MLflow ๋ชจ๋ธ๋ช
๋ค์์คํ์ด์ค. |
| ๋ฑ๋ก๋ช
์ {model_prefix}{intent_id}_sklearn. |
| seed: ๋์ ์๋ (์ฌํ์ฑ). |
| train_params: ํ์ต ํ์ดํผํ๋ผ๋ฏธํฐ(class_weight/C). |
| |
| Returns: |
| ํ์ต๋ Pipeline. ํ์ต ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฉด None. |
| """ |
| data = training_data.get(intent_id) |
| if data is None: |
| return None |
|
|
| feature_names = data["features"] |
| model_name = f"{model_prefix}{intent_id}_sklearn" |
|
|
| |
| extracted = _extract_from_dataset(intent_id, feature_names, seed, dataset_path) |
| if extracted is not None: |
| X, y = extracted |
| data_source = "seed_dataset" |
| elif "X" in data and "y" in data: |
| X, y = data["X"], data["y"] |
| data_source = "domain_knowledge" |
| else: |
| return None |
|
|
| pipe = _train_pipeline(X, y, seed=seed, train_params=train_params) |
|
|
| mlflow.set_tracking_uri(settings.MLFLOW_URI) |
| with mlflow.start_run(run_name=f"{model_name}_init"): |
| mlflow.sklearn.log_model( |
| pipe, |
| "model", |
| registered_model_name=model_name, |
| ) |
| mlflow.log_params({ |
| "intent_id": intent_id, |
| "n_features": len(feature_names), |
| "n_samples": len(y), |
| "n_positive": int(sum(y)), |
| "seed": seed, |
| "data_source": data_source, |
| "feature_names": ",".join(feature_names), |
| }) |
| train_acc = pipe.score(np.array(X, dtype=float), np.array(y)) |
| mlflow.log_metric("train_accuracy", train_acc) |
|
|
| logger.info(f"Trained + registered: {model_name} " |
| f"(source={data_source}, n={len(y)}, pos={int(sum(y))}, acc={train_acc:.3f})") |
| return pipe |
|
|
|
|
| def _load_or_train( |
| intent_id: str, |
| training_data: dict, |
| dataset_path: Path, |
| model_prefix: str, |
| train_params: dict | None = None, |
| ) -> Pipeline | None: |
| """๋ชจ๋ธ์ ์บ์โMLflow Registry ์์ผ๋ก ๋ก๋ํ๊ณ , ์์ผ๋ฉด ํ์ตยท๋ฑ๋กํ๋ค. |
| |
| ํ๋ก์ธ์ค ์บ์(_model_cache)๋ฅผ ์ฌ์ฉํด intent๋ณ๋ก 1ํ๋ง ๋ก๋/ํ์ตํ๋ค. |
| |
| Args: |
| intent_id: ๋ก๋/ํ์ตํ Intent ID. |
| training_data: Intent๋ณ ํ์ต ์ ์. |
| dataset_path: seed_dataset.json ๊ฒฝ๋ก. |
| model_prefix: ์๋๋ฆฌ์ค๋ณ MLflow ๋ชจ๋ธ๋ช
๋ค์์คํ์ด์ค. |
| train_params: ํ์ต ํ์ดํผํ๋ผ๋ฏธํฐ. |
| |
| Returns: |
| ๋ก๋ ๋๋ ํ์ต๋ Pipeline. ํ์ต ์ ์๊ฐ ์์ผ๋ฉด None. |
| """ |
| cache_key = f"{model_prefix}{intent_id}" |
| if cache_key in _model_cache: |
| return _model_cache[cache_key] |
|
|
| if intent_id not in training_data: |
| return None |
|
|
| mlflow.set_tracking_uri(settings.MLFLOW_URI) |
| uri = f"models:/{model_prefix}{intent_id}_sklearn/latest" |
| try: |
| pipe = mlflow.sklearn.load_model(uri) |
| except Exception: |
| pipe = train_and_register( |
| intent_id, training_data=training_data, |
| dataset_path=dataset_path, model_prefix=model_prefix, |
| train_params=train_params, |
| ) |
|
|
| _model_cache[cache_key] = pipe |
| return pipe |
|
|
|
|
| def predict( |
| intent_id: str, |
| features: dict[str, Any], |
| training_data: dict, |
| dataset_path: Path, |
| model_prefix: str, |
| train_params: dict | None = None, |
| ) -> float: |
| """Intent ID์ ๋ํด Model ๊ธฐ๋ฐ Score๋ฅผ ์ถ๋ก ํ๋ค. |
| |
| features dict์์ ํ์ต์ ์ฌ์ฉ๋ ํผ์ฒ๋ค์ ์์๋๋ก ์ถ์ถํ๋ฉฐ, |
| ๋๋ฝ๋ ํผ์ฒ๋ 0.0์ผ๋ก ์ฒ๋ฆฌํ๋ค. |
| |
| Args: |
| intent_id: ์ถ๋ก ํ Intent ID. |
| features: ์ถ๋ก ์ ์ฌ์ฉํ feature dict. |
| training_data: Intent๋ณ ํ์ต ์ ์(์๋๋ฆฌ์ค ์์ง ์ ๊ณต). |
| dataset_path: seed_dataset.json ๊ฒฝ๋ก(์๋๋ฆฌ์ค ์์ง ์ ๊ณต). |
| model_prefix: ์๋๋ฆฌ์ค๋ณ ๋ชจ๋ธ๋ช
๋ค์์คํ์ด์ค(์๋๋ฆฌ์ค ์์ง ์ ๊ณต). |
| train_params: ํ์ต ํ์ดํผํ๋ผ๋ฏธํฐ(class_weight/C, config L2.model.train). |
| |
| Returns: |
| 0~1 ๋ฒ์์ ์์ธก ์ ์. ๋ชจ๋ธ์ด ์์ผ๋ฉด 0.0. |
| """ |
| pipe = _load_or_train(intent_id, training_data, dataset_path, model_prefix, train_params) |
| if pipe is None: |
| return 0.0 |
|
|
| feature_names = training_data[intent_id]["features"] |
| x = np.array([[float(features.get(name, 0.0)) for name in feature_names]]) |
|
|
| proba = pipe.predict_proba(x)[0][1] |
| return float(proba) |
|
|
|
|
| def explain( |
| intent_id: str, |
| features: dict[str, Any], |
| training_data: dict, |
| dataset_path: Path, |
| model_prefix: str, |
| top: int = 3, |
| ) -> list[dict]: |
| """Model ์ถ๋ก ์ feature ๊ธฐ์ฌ๋๋ฅผ ๋ถํดํ๋ค. |
| |
| ์ ํ ํ์ดํ๋ผ์ธ(StandardScaler + LogisticRegression)์์ |
| ๊ธฐ์ฌ_i = coef_i ร ((x_i - mean_i) / scale_i)๋ก ๊ณ์ฐํ๊ณ , |
| |๊ธฐ์ฌ| ์์ top๊ฐ๋ฅผ ๋ฐํํ๋ค. |
| |
| Args: |
| intent_id: ๊ธฐ์ฌ๋๋ฅผ ๋ถํดํ Intent ID. |
| features: ์ถ๋ก ์ ์ฌ์ฉํ feature dict. |
| training_data: Intent๋ณ ํ์ต ์ ์. |
| dataset_path: seed_dataset.json ๊ฒฝ๋ก. |
| model_prefix: ์๋๋ฆฌ์ค๋ณ ๋ชจ๋ธ๋ช
๋ค์์คํ์ด์ค. |
| top: ๋ฐํํ ์์ ๊ธฐ์ฌ feature ๊ฐ์. |
| |
| Returns: |
| feature๋ณ ๊ธฐ์ฌ ์ ๋ณด(labelยทcontributionยทdirectionยทvalue) dict์ ๋ชฉ๋ก. |
| ๋ชจ๋ธ์ด ์๊ฑฐ๋ ๋ถํด์ ์คํจํ๋ฉด ๋น ๋ชฉ๋ก. |
| """ |
| pipe = _load_or_train(intent_id, training_data, dataset_path, model_prefix) |
| if pipe is None or intent_id not in training_data: |
| return [] |
| feats = training_data[intent_id]["features"] |
| x = np.array([float(features.get(n, 0.0)) for n in feats]) |
| try: |
| scaler = pipe.named_steps["scaler"] |
| lr = pipe.named_steps["lr"] |
| xs = (x - scaler.mean_) / scaler.scale_ |
| contrib = lr.coef_[0] * xs |
| except Exception: |
| return [] |
| items = sorted(zip(feats, contrib, x), key=lambda t: -abs(t[1]))[:top] |
| return [{"label": n, "contribution": round(float(c), 4), |
| "direction": "up" if c >= 0 else "down", "value": round(float(v), 2)} |
| for n, c, v in items] |
|
|
|
|
| def train_all( |
| training_data: dict, |
| dataset_path: Path, |
| model_prefix: str, |
| seed: int = 42, |
| ) -> dict[str, float]: |
| """training_data์ ๋ชจ๋ Model Intent๋ฅผ ํ์ตยท๋ฑ๋กํ๋ค. |
| |
| Args: |
| training_data: Intent๋ณ ํ์ต ์ ์. |
| dataset_path: seed_dataset.json ๊ฒฝ๋ก. |
| model_prefix: ์๋๋ฆฌ์ค๋ณ MLflow ๋ชจ๋ธ๋ช
๋ค์์คํ์ด์ค. |
| seed: ๋์ ์๋ (์ฌํ์ฑ). |
| |
| Returns: |
| ํ์ต์ ์ฑ๊ณตํ Intent์ ๋ํ {intent_id: 1.0} ๋งคํ. |
| """ |
| results = {} |
| for intent_id in training_data.keys(): |
| pipe = train_and_register( |
| intent_id, training_data=training_data, seed=seed, |
| dataset_path=dataset_path, model_prefix=model_prefix, |
| ) |
| if pipe is not None: |
| results[intent_id] = 1.0 |
| return results |
|
|