hpce-dev / models /sklearn_model.py
์ด๋™ํ˜„
[DOCS] ๋ฐฑ์—”๋“œ ์ „๋ฐ˜ docstring ๋ณด๊ฐ• (Google ์Šคํƒ€์ผ Args/Returns/Raises)
58debbc
Raw
History Blame Contribute Delete
11.5 kB
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] = {}
# โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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", []):
# batch + pattern + event ๋ฅผ ํ•ฉ์นœ ์ „์ฒด feature ๋ฒกํ„ฐ (์ถ”๋ก  ์‹œ์ ๊ณผ ๋™์ผ ๊ณต๊ฐ„)
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"
# 1) ์‹œ๋“œ ๋ฐ์ดํ„ฐ์…‹ ์šฐ์„ 
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