Spaces:
Running
Running
| """ | |
| model_ensemble.py | |
| ----------------- | |
| Multi-model ensemble for improved accuracy and robustness. | |
| Features: | |
| - Load multiple embedding models (all-MiniLM-L6-v2, all-mpnet-base-v2, bge-base-en) | |
| - Weighted voting across models | |
| - Individual model scores + ensemble score | |
| - Model comparison mode | |
| Author: SmartHire AI | |
| """ | |
| import logging | |
| from typing import Dict, List, Optional, Tuple | |
| import torch | |
| logger = logging.getLogger(__name__) | |
| # Supported models with their characteristics | |
| AVAILABLE_MODELS = { | |
| "all-MiniLM-L6-v2": { | |
| "name": "all-MiniLM-L6-v2", | |
| "weight": 0.5, # Higher weight - more reliable | |
| "dim": 384, | |
| "speed": "fast", | |
| "accuracy": "high", | |
| }, | |
| "all-mpnet-base-v2": { | |
| "name": "all-mpnet-base-v2", | |
| "weight": 0.3, # Medium weight | |
| "dim": 768, | |
| "speed": "medium", | |
| "accuracy": "very_high", | |
| }, | |
| "bge-base-en": { | |
| "name": "bge-base-en", | |
| "weight": 0.2, # Lower weight - newer model | |
| "dim": 768, | |
| "speed": "fast", | |
| "accuracy": "high", | |
| }, | |
| } | |
| class ModelEnsemble: | |
| """ | |
| Ensemble of multiple embedding models for robust matching. | |
| """ | |
| def __init__(self, model_names: Optional[List[str]] = None, use_weights: bool = True): | |
| """ | |
| Initialize ensemble with specified models. | |
| Args: | |
| model_names: List of model names. If None, uses primary only. | |
| use_weights: Whether to use weighted voting (vs equal voting). | |
| """ | |
| self.use_weights = use_weights | |
| self.models = {} | |
| self.model_names = model_names or ["all-MiniLM-L6-v2"] | |
| self._load_models() | |
| def _load_models(self) -> None: | |
| """Load all specified models.""" | |
| from sentence_transformers import SentenceTransformer | |
| import torch | |
| for model_name in self.model_names: | |
| try: | |
| logger.info(f"Loading ensemble model: {model_name}") | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| model = SentenceTransformer(model_name, device=device) | |
| self.models[model_name] = model | |
| except Exception as e: | |
| logger.warning(f"Failed to load {model_name}: {e}") | |
| if not self.models: | |
| raise RuntimeError("No models loaded successfully") | |
| logger.info(f"Ensemble ready with {len(self.models)} model(s)") | |
| def encode_all( | |
| self, | |
| texts: List[str], | |
| batch_size: int = 32, | |
| ) -> Dict[str, torch.Tensor]: | |
| """ | |
| Encode texts with all models. | |
| Returns: | |
| Dict mapping model_name -> embeddings tensor [N, dim] | |
| """ | |
| embeddings = {} | |
| for model_name, model in self.models.items(): | |
| logger.debug(f"Encoding with {model_name}") | |
| embs = model.encode( | |
| texts, | |
| batch_size=batch_size, | |
| convert_to_tensor=True, | |
| normalize_embeddings=True, | |
| ) | |
| embeddings[model_name] = embs.cpu() | |
| return embeddings | |
| def ensemble_similarity( | |
| self, | |
| resume_embeddings: Dict[str, torch.Tensor], | |
| jd_embedding: Dict[str, torch.Tensor], | |
| ) -> Tuple[float, Dict[str, float]]: | |
| """ | |
| Compute weighted ensemble similarity score. | |
| Returns: | |
| (ensemble_score, individual_scores_dict) | |
| """ | |
| individual_scores = {} | |
| total_weight = 0.0 | |
| weighted_sum = 0.0 | |
| for model_name in self.models.keys(): | |
| resume_emb = resume_embeddings[model_name] | |
| jd_emb = jd_embedding[model_name] | |
| # Cosine similarity | |
| sim = torch.nn.functional.cosine_similarity( | |
| resume_emb, jd_emb, dim=1 | |
| ) | |
| sim_score = float(sim.mean()) | |
| individual_scores[model_name] = round(sim_score, 4) | |
| # Weighted sum | |
| weight = AVAILABLE_MODELS[model_name]["weight"] if self.use_weights else 1.0 | |
| weighted_sum += sim_score * weight | |
| total_weight += weight | |
| ensemble_score = weighted_sum / total_weight if total_weight > 0 else 0.0 | |
| return round(ensemble_score, 4), individual_scores | |
| def get_model_info(self) -> Dict: | |
| """Return info about loaded models.""" | |
| return { | |
| "num_models": len(self.models), | |
| "models": { | |
| name: { | |
| "dim": AVAILABLE_MODELS.get(name, {}).get("dim", "unknown"), | |
| "weight": AVAILABLE_MODELS.get(name, {}).get("weight", 0), | |
| "speed": AVAILABLE_MODELS.get(name, {}).get("speed", "unknown"), | |
| "accuracy": AVAILABLE_MODELS.get(name, {}).get("accuracy", "unknown"), | |
| } | |
| for name in self.models.keys() | |
| }, | |
| "use_weights": self.use_weights, | |
| } | |
| def compare_models( | |
| resume_text: str, | |
| jd_text: str, | |
| ) -> Dict: | |
| """ | |
| Compare all available models on a single match. | |
| Returns: | |
| { | |
| "all-MiniLM-L6-v2": 0.845, | |
| "all-mpnet-base-v2": 0.862, | |
| "bge-base-en": 0.851, | |
| "ensemble": 0.853, | |
| } | |
| """ | |
| from sentence_transformers import SentenceTransformer | |
| import torch | |
| results = {} | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # Individual models | |
| for model_name in AVAILABLE_MODELS.keys(): | |
| try: | |
| model = SentenceTransformer(model_name, device=device) | |
| resume_emb = model.encode(resume_text, convert_to_tensor=True, normalize_embeddings=True) | |
| jd_emb = model.encode(jd_text, convert_to_tensor=True, normalize_embeddings=True) | |
| sim = float(torch.nn.functional.cosine_similarity(resume_emb.unsqueeze(0), jd_emb.unsqueeze(0))) | |
| results[model_name] = round(sim, 4) | |
| except Exception as e: | |
| logger.warning(f"Failed to compare with {model_name}: {e}") | |
| results[model_name] = None | |
| # Ensemble | |
| valid_scores = [s for s in results.values() if s is not None] | |
| if valid_scores: | |
| ensemble_score = sum(valid_scores) / len(valid_scores) | |
| results["ensemble"] = round(ensemble_score, 4) | |
| return results | |