File size: 6,307 Bytes
91e794e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
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