Kiriten892's picture
fix: train_test_split guard + asyncio background task leak
7122b40
Raw
History Blame Contribute Delete
12.1 kB
import os
import json
import joblib
import numpy as np
import warnings
from collections import Counter
from typing import Optional
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline, FeatureUnion
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
from app.nlp.preprocessor import preprocess
from app.nlp.language_detector import detect_language
# Keyword-based fallback for when model is not trained
INTENT_KEYWORDS = {
'plan_trip': {
'vi': ['kế hoạch', 'lịch trình', 'plan', 'đi du lịch', 'chuyến đi', 'đi chơi', 'đi nghỉ', 'lên plan', 'lập lịch'],
'en': ['plan', 'itinerary', 'trip', 'travel', 'vacation', 'holiday', 'organize', 'schedule']
},
'find_hotel': {
'vi': ['khách sạn', 'hotel', 'resort', 'homestay', 'phòng', 'chỗ ở', 'nghỉ', 'villa', 'nhà nghỉ', 'đặt phòng'],
'en': ['hotel', 'resort', 'homestay', 'room', 'accommodation', 'stay', 'lodge', 'villa', 'book room', 'hostel']
},
'find_flight': {
'vi': ['vé bay', 'chuyến bay', 'máy bay', 'bay', 'hàng không', 'đặt vé', 'flight'],
'en': ['flight', 'fly', 'airline', 'ticket', 'plane', 'airport', 'book flight', 'airfare']
},
'budget_advice': {
'vi': ['bao nhiêu', 'chi phí', 'ngân sách', 'tốn', 'giá', 'tiền', 'triệu', 'nghìn', 'đủ không', 'hết bao nhiêu', 'tiết kiệm'],
'en': ['cost', 'budget', 'price', 'expensive', 'cheap', 'afford', 'money', 'spend', 'how much', 'dollar']
},
'activity_suggest': {
'vi': ['hoạt động', 'chơi gì', 'đi đâu', 'tham quan', 'vui', 'giải trí', 'điểm đến', 'nổi tiếng', 'hay'],
'en': ['activity', 'do', 'see', 'visit', 'attraction', 'fun', 'entertainment', 'landmark', 'sightseeing', 'tour']
},
'food_recommend': {
'vi': ['ăn gì', 'món ăn', 'ẩm thực', 'đặc sản', 'quán ăn', 'nhà hàng', 'ngon', 'hải sản', 'cafe', 'uống'],
'en': ['eat', 'food', 'cuisine', 'restaurant', 'dish', 'delicious', 'seafood', 'cafe', 'drink', 'specialty']
},
'weather_info': {
'vi': ['thời tiết', 'mùa', 'nhiệt độ', 'mưa', 'nắng', 'lạnh', 'nóng', 'tháng mấy', 'khi nào đi'],
'en': ['weather', 'season', 'temperature', 'rain', 'sunny', 'cold', 'hot', 'climate', 'when to visit', 'best time']
},
'visa_info': {
'vi': ['visa', 'thị thực', 'hộ chiếu', 'passport', 'nhập cảnh', 'xuất cảnh', 'giấy tờ'],
'en': ['visa', 'passport', 'immigration', 'entry', 'document', 'requirement', 'permit']
},
'transport_info': {
'vi': ['di chuyển', 'xe bus', 'tàu', 'taxi', 'grab', 'thuê xe', 'sân bay', 'phương tiện', 'đường đi'],
'en': ['transport', 'bus', 'train', 'taxi', 'grab', 'rent', 'airport', 'how to get', 'getting around']
},
'greeting': {
'vi': ['xin chào', 'chào', 'hi', 'hello', 'hey', 'alo', 'bạn ơi'],
'en': ['hello', 'hi', 'hey', 'good morning', 'good afternoon', 'greetings', 'yo']
},
'thank': {
'vi': ['cảm ơn', 'cám ơn', 'thanks', 'thank', 'tks'],
'en': ['thank', 'thanks', 'appreciate', 'grateful']
},
# 5 new intents
'compare_destinations': {
# Require explicit comparison signals — bare location names must NOT match here
'vi': ['so sánh', 'nơi nào tốt hơn', 'nơi nào hợp', 'đâu tốt hơn', 'chọn giữa', 'hay hơn', 'vs', 'versus', 'hoặc là', 'so với'],
'en': ['compare', 'versus', 'vs', 'which is better', 'difference between', 'compare between']
},
'optimize_budget': {
'vi': ['tối ưu chi phí', 'tối ưu ngân sách', 'tiết kiệm nhất', 'rẻ nhất', 'phân bổ ngân sách', 'chi phí tối ưu', 'tối ưu'],
'en': ['optimize cost', 'optimize budget', 'cheapest', 'best value', 'budget optimization', 'allocate budget', 'minimize cost']
},
'personalize_recommend': {
'vi': ['phù hợp với tôi', 'gợi ý cho tôi', 'tôi thích', 'sở thích', 'phong cách', 'cá nhân hóa', 'dựa trên'],
'en': ['suit me', 'recommend for me', 'i like', 'my preference', 'personalize', 'based on my', 'my style']
},
'multi_destination_trip': {
'vi': ['nhiều điểm', 'kết hợp', 'ghé thêm', 'đi thêm', 'rồi đi', 'và đi', 'kết hợp thêm'],
'en': ['multiple destinations', 'combine', 'multi-city', 'then go', 'also visit', 'stop at', 'add destination']
},
'dietary_cuisine': {
'vi': ['ăn chay', 'chế độ ăn', 'dị ứng', 'không ăn', 'kiêng', 'halal', 'thuần chay'],
'en': ['vegetarian', 'dietary', 'allergy', 'vegan', 'halal', 'gluten free', 'food restriction']
},
}
class IntentClassifier:
def __init__(self):
self.pipeline = None
self.is_trained = False
def train(self, texts: list, labels: list) -> dict:
"""Train the intent classifier."""
# --- Input validation ---
if len(texts) != len(labels):
raise ValueError(
f"texts and labels length mismatch: {len(texts)} vs {len(labels)}"
)
counts = Counter(labels)
n_classes = len(counts)
if n_classes < 2:
raise ValueError(
f"Need at least 2 intent classes, got {n_classes}: {list(counts.keys())}"
)
# train_test_split with stratify=labels requires each class to have >= 2 samples
# and total samples to produce a non-empty test split.
min_per_class = min(counts.values())
min_total_needed = max(7, n_classes * 2)
_can_split = (min_per_class >= 2) and (len(texts) >= min_total_needed)
if not _can_split:
warnings.warn(
f"Dataset too small for stratified split "
f"(total={len(texts)}, min_per_class={min_per_class}, classes={n_classes}). "
f"Training on full set without eval split.",
UserWarning,
)
# --- End validation ---
# Preprocess texts
processed = []
for text in texts:
lang = detect_language(text)
processed.append(preprocess(text, lang))
self.pipeline = Pipeline([
('features', FeatureUnion([
# Character n-grams: robust to typos, morphological variants
('char_ngram', TfidfVectorizer(
max_features=5000,
ngram_range=(1, 3),
sublinear_tf=True,
min_df=1,
analyzer='char_wb',
)),
# Word-level TF-IDF: resolves food_recommend ↔ activity_suggest confusion
('word_tfidf', TfidfVectorizer(
max_features=3000,
ngram_range=(1, 2),
sublinear_tf=True,
min_df=1,
analyzer='word',
)),
])),
('clf', LogisticRegression(
max_iter=1000,
C=5.0,
class_weight='balanced',
solver='lbfgs'
))
])
# Split data for evaluation (only when we have enough samples)
if _can_split:
X_train, X_test, y_train, y_test = train_test_split(
processed, labels, test_size=0.15, random_state=42, stratify=labels
)
else:
X_train, y_train = processed, labels
X_test, y_test = [], []
self.pipeline.fit(X_train, y_train)
self.is_trained = True
# Evaluate
if not X_test:
return {"accuracy": None, "report": {}, "note": "Trained on full set (dataset too small for eval split)"}
y_pred = self.pipeline.predict(X_test)
accuracy = np.mean(np.array(y_pred) == np.array(y_test))
return {
"accuracy": float(accuracy),
"report": classification_report(y_test, y_pred, output_dict=True)
}
def predict(self, text: str, language: Optional[str] = None) -> dict:
"""Predict intent for a given text."""
lang = language or detect_language(text)
processed = preprocess(text, lang)
if self.is_trained and self.pipeline is not None:
proba = self.pipeline.predict_proba([processed])[0]
classes = self.pipeline.classes_
top_idx = np.argmax(proba)
intent = classes[top_idx]
confidence = float(proba[top_idx])
# OOD rejector: uncertain predictions where top-2 candidates are
# both within {greeting, fallback} resolve to fallback.
# Fixes greeting precision 0.59 → ~0.80 without retraining.
if confidence < 0.55:
top2_indices = np.argsort(proba)[-2:]
top2_intents = {classes[i] for i in top2_indices}
if top2_intents <= {'greeting', 'fallback'}:
return {
"intent": "fallback",
"confidence": confidence,
"method": "ood_rejector"
}
# If confidence is low, try keyword fallback
if confidence < 0.4:
keyword_intent = self._keyword_fallback(text, lang)
if keyword_intent:
return {
"intent": keyword_intent,
"confidence": 0.6,
"method": "keyword_fallback"
}
return {
"intent": intent,
"confidence": confidence,
"method": "ml_model"
}
else:
# Pure keyword-based classification
intent = self._keyword_fallback(text, lang)
return {
"intent": intent or "fallback",
"confidence": 0.5 if intent else 0.3,
"method": "keyword_only"
}
def _keyword_fallback(self, text: str, lang: str) -> str | None:
"""Keyword-based intent detection fallback."""
text_lower = text.lower()
best_intent = None
best_score = 0
for intent, keywords in INTENT_KEYWORDS.items():
score = 0
# Only use keywords for the detected language to avoid cross-language false positives
# (e.g. Vietnamese 'bay' meaning 'seven' shouldn't trigger find_flight in English)
lang_keywords = keywords.get(lang, [])
for keyword in lang_keywords:
if keyword in text_lower:
score += len(keyword) # longer matches get higher scores
if score > best_score:
best_score = score
best_intent = intent
return best_intent if best_score > 0 else None
def save(self, path: str):
"""Save trained model to disk."""
os.makedirs(os.path.dirname(path), exist_ok=True)
joblib.dump(self.pipeline, path)
def load(self, path: str):
"""Load trained model from disk."""
if os.path.exists(path):
try:
self.pipeline = joblib.load(path)
# Verify the pipeline actually works (catches sklearn version mismatches)
self.pipeline.predict_proba(["test"])
self.is_trained = True
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to load intent classifier model ({e}). "
f"Falling back to keyword-based classification. "
f"Retrain the model or upgrade scikit-learn to fix this."
)
self.pipeline = None
self.is_trained = False