bat-6 commited on
Commit
c3cf4e5
·
verified ·
1 Parent(s): 1b6d8b7

Update: hybrid_ranker.py

Browse files
Files changed (1) hide show
  1. src/similarity_model/hybrid_ranker.py +327 -327
src/similarity_model/hybrid_ranker.py CHANGED
@@ -1,327 +1,327 @@
1
- import logging
2
- from typing import List, Dict, Any
3
-
4
- import pandas as pd
5
-
6
- from src.similarity_model import (
7
- compute_feature_similarity,
8
- load_feature_model,
9
- safe_feature_list
10
- )
11
-
12
- logging.basicConfig(
13
- level=logging.INFO,
14
- format="%(asctime)s | %(levelname)s | %(message)s"
15
- )
16
- logger = logging.getLogger(__name__)
17
-
18
- TITLE_COL = "project_title"
19
- FEATURE_COL = "features"
20
-
21
- DEFAULT_SEMANTIC_WEIGHT = 0.65
22
- DEFAULT_FEATURE_WEIGHT = 0.35
23
-
24
- HIGH_FEATURE_WEIGHT = 0.45
25
- LOW_FEATURE_WEIGHT = 0.20
26
-
27
- BONUS_WEIGHT = 0.05
28
- MIN_HYBRID_SCORE = 0.05
29
-
30
- def clamp(value: float) -> float:
31
- """
32
- Keep score inside [0,1]
33
- """
34
- return max(0.0, min(1.0, float(value)))
35
-
36
- def get_dynamic_weights(
37
- feature_count: int,
38
- coverage: float
39
- ):
40
- """
41
- Adaptive weights depending on feature richness.
42
- Returns (semantic_w, feature_w, coverage_w) — always sum to 1.0
43
-
44
- NOTE: When coverage==0 (DB project has no stored features),
45
- always fall back to the high-semantic-weight path so that
46
- a rich multi-feature query is not penalised vs a sparse one.
47
- """
48
-
49
- # No feature evidence at all — rely on semantic regardless of query richness
50
- # Use 0.50 weight (not 0.70) to compensate for all-mpnet-base-v2's higher
51
- # within-domain baseline scores; prevents same-domain/different-purpose
52
- # projects from scoring too high when no features overlap.
53
- if coverage == 0:
54
- return 0.50, 0.30, 0.20
55
-
56
- # Rich features + high overlap → trust features heavily
57
- if feature_count >= 5 and coverage >= 0.60:
58
- return 0.40, 0.45, 0.15
59
-
60
- # Sparse query features
61
- if feature_count <= 2:
62
- return 0.70, 0.20, 0.10
63
-
64
- # Balanced
65
- return 0.55, 0.35, 0.10
66
-
67
- def compute_hybrid_score(
68
- semantic_score,
69
- feature_score,
70
- coverage,
71
- feature_count,
72
- unique_query_count=0
73
- ) -> float:
74
-
75
- semantic_score = clamp(semantic_score)
76
- feature_score = clamp(feature_score)
77
- coverage = clamp(coverage)
78
-
79
- shared_count = feature_count - unique_query_count
80
-
81
- # Hard duplicate: most features shared, almost nothing unique
82
- if shared_count >= 6 and unique_query_count <= 1:
83
- return 0.95
84
-
85
- # Near-total feature coverage with high similarity
86
- if coverage >= 0.90 and feature_score >= 0.65:
87
- return round(
88
- clamp(
89
- 0.75 +
90
- (0.15 * feature_score) +
91
- (0.10 * semantic_score)
92
- ),
93
- 4
94
- )
95
-
96
- # Get adaptive weights regardless of feature availability
97
- semantic_w, feature_w, coverage_w = get_dynamic_weights(
98
- feature_count, coverage
99
- )
100
-
101
- score = (
102
- semantic_w * semantic_score +
103
- feature_w * feature_score +
104
- coverage_w * coverage
105
- )
106
-
107
- # Only penalise low feature score when features actually exist to compare.
108
- # Skipping this when feature_score==0 prevents an unfair 50% penalty
109
- # on DB projects that have no stored features.
110
- if feature_score > 0 and feature_score < 0.40:
111
- penalty = 0.50 + (feature_score / 0.40) * 0.50
112
- score *= penalty
113
-
114
- return round(clamp(score), 4)
115
-
116
- def compute_originality(
117
- hybrid_score: float,
118
- unique_query_features: int,
119
- total_query_features: int
120
- ) -> float:
121
- """
122
- Originality Score (0-100).
123
- Base: (1 - hybrid_score) * 100.
124
-
125
- Uniqueness bonus (+up to 10 pts) is only applied when the DB project
126
- actually has stored features (total_query_features > 0 AND there were
127
- real feature matches to compare against). While DB features are empty,
128
- unique_query_features == total_query_features always, making the bonus
129
- a constant +10 noise term — so we skip it until the parquet is rebuilt.
130
- """
131
- hybrid_score = clamp(hybrid_score)
132
-
133
- originality = 100.0 * (1.0 - hybrid_score)
134
-
135
- # Only apply uniqueness bonus when feature comparison was meaningful
136
- # (i.e. the candidate had stored features, so coverage > 0)
137
- # Guarded by unique < total to avoid the always-1.0 ratio when DB is empty
138
- if total_query_features > 0 and unique_query_features < total_query_features:
139
- uniqueness_ratio = unique_query_features / total_query_features
140
- originality = min(100.0, originality + (uniqueness_ratio * 10.0))
141
-
142
- return round(max(0.0, min(100.0, originality)), 2)
143
-
144
- def compute_confidence(
145
- semantic_score: float,
146
- feature_score: float,
147
- coverage: float
148
- ) -> float:
149
- """
150
- Trust score for ranking result.
151
- """
152
-
153
- confidence = (
154
- 0.40 * clamp(semantic_score) +
155
- 0.40 * clamp(feature_score) +
156
- 0.20 * clamp(coverage)
157
- )
158
-
159
- return round(clamp(confidence), 4)
160
-
161
- def risk_label(score: float) -> str:
162
- """
163
- Duplicate risk label.
164
- """
165
-
166
- if score >= 0.90:
167
- return "Very High"
168
-
169
- if score >= 0.75:
170
- return "High"
171
-
172
- if score >= 0.55:
173
- return "Medium"
174
-
175
- if score >= 0.35:
176
- return "Low"
177
-
178
- return "Very Low"
179
-
180
- def compare_single_candidate(
181
- query_row: Dict[str, Any],
182
- candidate_row: Dict[str, Any],
183
- semantic_score: float,
184
- model=None
185
- ) -> Dict[str, Any]:
186
-
187
- if model is None:
188
- model = load_feature_model()
189
-
190
- query_features = safe_feature_list(
191
- query_row.get(FEATURE_COL, [])
192
- )
193
-
194
- candidate_features = safe_feature_list(
195
- candidate_row.get(FEATURE_COL, [])
196
- )
197
-
198
- feature_result = compute_feature_similarity(
199
- query_features,
200
- candidate_features,
201
- model=model
202
- )
203
-
204
- feature_score = feature_result["score"]
205
- coverage = feature_result["coverage"]
206
-
207
- total_query_features = len(query_features)
208
- unique_query_count = len(
209
- feature_result["unique_a"]
210
- )
211
-
212
- base_similarity = compute_hybrid_score(
213
- semantic_score=semantic_score,
214
- feature_score=feature_score,
215
- coverage=coverage,
216
- feature_count=total_query_features,
217
- unique_query_count=unique_query_count
218
- )
219
-
220
- originality_score = compute_originality(
221
- hybrid_score=base_similarity,
222
- unique_query_features=unique_query_count,
223
- total_query_features=total_query_features
224
- )
225
-
226
- hybrid_score = base_similarity
227
-
228
- confidence_score = compute_confidence(
229
- semantic_score=semantic_score,
230
- feature_score=feature_score,
231
- coverage=coverage
232
- )
233
- print("=" * 50)
234
- print("BASE SIMILARITY:", base_similarity)
235
- print("ORIGINALITY:", originality_score)
236
- print("FINAL SIMILARITY:", hybrid_score)
237
- print("=" * 50)
238
- return {
239
- "project_title":
240
- candidate_row.get(TITLE_COL, ""),
241
-
242
- "semantic_score":
243
- round(float(semantic_score), 4),
244
-
245
- "feature_score":
246
- feature_score,
247
-
248
- "coverage":
249
- coverage,
250
-
251
- "hybrid_score":
252
- hybrid_score,
253
-
254
- "originality_score":
255
- originality_score,
256
-
257
- "confidence_score":
258
- confidence_score,
259
-
260
- "duplicate_risk":
261
- risk_label(hybrid_score),
262
-
263
- "shared_features_count":
264
- feature_result["shared_count"],
265
-
266
- "matches":
267
- feature_result["matches"],
268
-
269
- "unique_query_features":
270
- feature_result["unique_a"],
271
-
272
- "unique_candidate_features":
273
- feature_result["unique_b"],
274
-
275
- "candidate_features":
276
- candidate_features
277
- }
278
-
279
- def rank_candidates(
280
- query_row,
281
- candidate_rows,
282
- semantic_scores: List[float],
283
- model=None,
284
- min_score: float = MIN_HYBRID_SCORE
285
- ) -> pd.DataFrame:
286
- """
287
- Re-rank semantic retrieval results
288
- using hybrid logic.
289
- """
290
-
291
- if model is None:
292
- model = load_feature_model()
293
-
294
- rows = []
295
-
296
- for candidate_row, sem_score in zip(
297
- candidate_rows,
298
- semantic_scores
299
- ):
300
-
301
- result = compare_single_candidate(
302
- query_row=query_row,
303
- candidate_row=candidate_row,
304
- semantic_score=float(sem_score),
305
- model=model
306
- )
307
-
308
- if result["hybrid_score"] >= min_score:
309
- rows.append(result)
310
-
311
- if not rows:
312
- return pd.DataFrame([{
313
- "message": "No strong similar projects found."
314
- }])
315
-
316
- results = pd.DataFrame(rows)
317
-
318
- results = results.sort_values(
319
- by=[
320
- "hybrid_score",
321
- "confidence_score",
322
- "semantic_score"
323
- ],
324
- ascending=False
325
- ).reset_index(drop=True)
326
-
327
- return results
 
1
+ import logging
2
+ from typing import List, Dict, Any
3
+
4
+ import pandas as pd
5
+
6
+ from src.similarity_model import (
7
+ compute_feature_similarity,
8
+ load_feature_model,
9
+ safe_feature_list
10
+ )
11
+
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format="%(asctime)s | %(levelname)s | %(message)s"
15
+ )
16
+ logger = logging.getLogger(__name__)
17
+
18
+ TITLE_COL = "project_title"
19
+ FEATURE_COL = "features"
20
+
21
+ DEFAULT_SEMANTIC_WEIGHT = 0.65
22
+ DEFAULT_FEATURE_WEIGHT = 0.35
23
+
24
+ HIGH_FEATURE_WEIGHT = 0.45
25
+ LOW_FEATURE_WEIGHT = 0.20
26
+
27
+ BONUS_WEIGHT = 0.05
28
+ MIN_HYBRID_SCORE = 0.05
29
+
30
+ def clamp(value: float) -> float:
31
+ """
32
+ Keep score inside [0,1]
33
+ """
34
+ return max(0.0, min(1.0, float(value)))
35
+
36
+ def get_dynamic_weights(
37
+ feature_count: int,
38
+ coverage: float
39
+ ):
40
+ """
41
+ Adaptive weights depending on feature richness.
42
+ Returns (semantic_w, feature_w, coverage_w) — always sum to 1.0
43
+
44
+ NOTE: When coverage==0 (DB project has no stored features),
45
+ always fall back to the high-semantic-weight path so that
46
+ a rich multi-feature query is not penalised vs a sparse one.
47
+ """
48
+
49
+ # No feature evidence at all — rely on semantic regardless of query richness
50
+ # Use 0.50 weight (not 0.70) to compensate for all-mpnet-base-v2's higher
51
+ # within-domain baseline scores; prevents same-domain/different-purpose
52
+ # projects from scoring too high when no features overlap.
53
+ if coverage == 0:
54
+ return 0.50, 0.30, 0.20
55
+
56
+ # Rich features + high overlap → trust features heavily
57
+ if feature_count >= 5 and coverage >= 0.60:
58
+ return 0.40, 0.45, 0.15
59
+
60
+ # Sparse query features
61
+ if feature_count <= 2:
62
+ return 0.70, 0.20, 0.10
63
+
64
+ # Balanced
65
+ return 0.55, 0.35, 0.10
66
+
67
+ def compute_hybrid_score(
68
+ semantic_score,
69
+ feature_score,
70
+ coverage,
71
+ feature_count,
72
+ unique_query_count=0
73
+ ) -> float:
74
+
75
+ semantic_score = clamp(semantic_score)
76
+ feature_score = clamp(feature_score)
77
+ coverage = clamp(coverage)
78
+
79
+ shared_count = feature_count - unique_query_count
80
+
81
+ # Hard duplicate: most features shared, almost nothing unique
82
+ if shared_count >= 6 and unique_query_count <= 1:
83
+ return 0.95
84
+
85
+ # Near-total feature coverage with high similarity
86
+ if coverage >= 0.90 and feature_score >= 0.65:
87
+ return round(
88
+ clamp(
89
+ 0.75 +
90
+ (0.15 * feature_score) +
91
+ (0.10 * semantic_score)
92
+ ),
93
+ 4
94
+ )
95
+
96
+ # Get adaptive weights regardless of feature availability
97
+ semantic_w, feature_w, coverage_w = get_dynamic_weights(
98
+ feature_count, coverage
99
+ )
100
+
101
+ score = (
102
+ semantic_w * semantic_score +
103
+ feature_w * feature_score +
104
+ coverage_w * coverage
105
+ )
106
+
107
+ # Only penalise low feature score when features actually exist to compare.
108
+ # Skipping this when feature_score==0 prevents an unfair 50% penalty
109
+ # on DB projects that have no stored features.
110
+ if feature_score > 0 and feature_score < 0.40:
111
+ penalty = 0.50 + (feature_score / 0.40) * 0.50
112
+ score *= penalty
113
+
114
+ return round(clamp(score), 4)
115
+
116
+ def compute_originality(
117
+ hybrid_score: float,
118
+ unique_query_features: int,
119
+ total_query_features: int
120
+ ) -> float:
121
+ """
122
+ Originality Score (0-100).
123
+ Base: (1 - hybrid_score) * 100.
124
+
125
+ Uniqueness bonus (+up to 10 pts) is only applied when the DB project
126
+ actually has stored features (total_query_features > 0 AND there were
127
+ real feature matches to compare against). While DB features are empty,
128
+ unique_query_features == total_query_features always, making the bonus
129
+ a constant +10 noise term — so we skip it until the parquet is rebuilt.
130
+ """
131
+ hybrid_score = clamp(hybrid_score)
132
+
133
+ originality = 100.0 * (1.0 - hybrid_score)
134
+
135
+ # Only apply uniqueness bonus when feature comparison was meaningful
136
+ # (i.e. the candidate had stored features, so coverage > 0)
137
+ # Guarded by unique < total to avoid the always-1.0 ratio when DB is empty
138
+ if total_query_features > 0 and unique_query_features < total_query_features:
139
+ uniqueness_ratio = unique_query_features / total_query_features
140
+ originality = min(100.0, originality + (uniqueness_ratio * 10.0))
141
+
142
+ return round(max(0.0, min(100.0, originality)), 2)
143
+
144
+ def compute_confidence(
145
+ semantic_score: float,
146
+ feature_score: float,
147
+ coverage: float
148
+ ) -> float:
149
+ """
150
+ Trust score for ranking result.
151
+ """
152
+
153
+ confidence = (
154
+ 0.40 * clamp(semantic_score) +
155
+ 0.40 * clamp(feature_score) +
156
+ 0.20 * clamp(coverage)
157
+ )
158
+
159
+ return round(clamp(confidence), 4)
160
+
161
+ def risk_label(score: float) -> str:
162
+ """
163
+ Duplicate risk label.
164
+ """
165
+
166
+ if score >= 0.90:
167
+ return "Very High"
168
+
169
+ if score >= 0.75:
170
+ return "High"
171
+
172
+ if score >= 0.55:
173
+ return "Medium"
174
+
175
+ if score >= 0.35:
176
+ return "Low"
177
+
178
+ return "Very Low"
179
+
180
+ def compare_single_candidate(
181
+ query_row: Dict[str, Any],
182
+ candidate_row: Dict[str, Any],
183
+ semantic_score: float,
184
+ model=None
185
+ ) -> Dict[str, Any]:
186
+
187
+ if model is None:
188
+ model = load_feature_model()
189
+
190
+ query_features = safe_feature_list(
191
+ query_row.get(FEATURE_COL, [])
192
+ )
193
+
194
+ candidate_features = safe_feature_list(
195
+ candidate_row.get(FEATURE_COL, [])
196
+ )
197
+
198
+ feature_result = compute_feature_similarity(
199
+ query_features,
200
+ candidate_features,
201
+ model=model
202
+ )
203
+
204
+ feature_score = feature_result["score"]
205
+ coverage = feature_result["coverage"]
206
+
207
+ total_query_features = len(query_features)
208
+ unique_query_count = len(
209
+ feature_result["unique_a"]
210
+ )
211
+
212
+ base_similarity = compute_hybrid_score(
213
+ semantic_score=semantic_score,
214
+ feature_score=feature_score,
215
+ coverage=coverage,
216
+ feature_count=total_query_features,
217
+ unique_query_count=unique_query_count
218
+ )
219
+
220
+ originality_score = compute_originality(
221
+ hybrid_score=base_similarity,
222
+ unique_query_features=unique_query_count,
223
+ total_query_features=total_query_features
224
+ )
225
+
226
+ hybrid_score = base_similarity
227
+
228
+ confidence_score = compute_confidence(
229
+ semantic_score=semantic_score,
230
+ feature_score=feature_score,
231
+ coverage=coverage
232
+ )
233
+ print("=" * 50)
234
+ print("BASE SIMILARITY:", base_similarity)
235
+ print("ORIGINALITY:", originality_score)
236
+ print("FINAL SIMILARITY:", hybrid_score)
237
+ print("=" * 50)
238
+ return {
239
+ "project_title":
240
+ candidate_row.get(TITLE_COL, ""),
241
+
242
+ "semantic_score":
243
+ round(float(semantic_score), 4),
244
+
245
+ "feature_score":
246
+ feature_score,
247
+
248
+ "coverage":
249
+ coverage,
250
+
251
+ "hybrid_score":
252
+ hybrid_score,
253
+
254
+ "originality_score":
255
+ originality_score,
256
+
257
+ "confidence_score":
258
+ confidence_score,
259
+
260
+ "duplicate_risk":
261
+ risk_label(hybrid_score),
262
+
263
+ "shared_features_count":
264
+ feature_result["shared_count"],
265
+
266
+ "matches":
267
+ feature_result["matches"],
268
+
269
+ "unique_query_features":
270
+ feature_result["unique_a"],
271
+
272
+ "unique_candidate_features":
273
+ feature_result["unique_b"],
274
+
275
+ "candidate_features":
276
+ candidate_features
277
+ }
278
+
279
+ def rank_candidates(
280
+ query_row,
281
+ candidate_rows,
282
+ semantic_scores: List[float],
283
+ model=None,
284
+ min_score: float = MIN_HYBRID_SCORE
285
+ ) -> pd.DataFrame:
286
+ """
287
+ Re-rank semantic retrieval results
288
+ using hybrid logic.
289
+ """
290
+
291
+ if model is None:
292
+ model = load_feature_model()
293
+
294
+ rows = []
295
+
296
+ for candidate_row, sem_score in zip(
297
+ candidate_rows,
298
+ semantic_scores
299
+ ):
300
+
301
+ result = compare_single_candidate(
302
+ query_row=query_row,
303
+ candidate_row=candidate_row,
304
+ semantic_score=float(sem_score),
305
+ model=model
306
+ )
307
+
308
+ if result["hybrid_score"] >= min_score:
309
+ rows.append(result)
310
+
311
+ if not rows:
312
+ return pd.DataFrame([{
313
+ "message": "No strong similar projects found."
314
+ }])
315
+
316
+ results = pd.DataFrame(rows)
317
+
318
+ results = results.sort_values(
319
+ by=[
320
+ "hybrid_score",
321
+ "confidence_score",
322
+ "semantic_score"
323
+ ],
324
+ ascending=False
325
+ ).reset_index(drop=True)
326
+
327
+ return results