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

Update: feature_similarity.py

Browse files
src/similarity_model/feature_similarity.py CHANGED
@@ -1,235 +1,235 @@
1
- from typing import List, Dict, Any
2
- import pandas as pd
3
- from sentence_transformers import SentenceTransformer
4
- from scipy.optimize import linear_sum_assignment
5
- from sklearn.metrics.pairwise import cosine_similarity
6
- import logging
7
- from functools import lru_cache
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
- MODEL_NAME = "all-mpnet-base-v2"
12
- SIMILARITY_WEIGHT = 0.70
13
- COVERAGE_WEIGHT = 0.30
14
- DEFAULT_THRESHOLD = 0.65
15
-
16
- @lru_cache(maxsize=1)
17
- def load_feature_model():
18
- logger.info(f"Loading feature model: {MODEL_NAME}")
19
- return SentenceTransformer(MODEL_NAME)
20
-
21
- def safe_feature_list(features):
22
- """
23
- Convert any feature input into clean List[str]
24
- """
25
- import numpy as np
26
- import json
27
- import ast
28
-
29
- if features is None:
30
- return []
31
-
32
- if isinstance(features, float) and pd.isna(features):
33
- return []
34
-
35
- if isinstance(features, np.ndarray):
36
- features = features.tolist()
37
-
38
- if isinstance(features, tuple):
39
- features = list(features)
40
-
41
- if isinstance(features, str):
42
- features = features.strip()
43
- parsed = None
44
- # Try JSON parsing
45
- try:
46
- parsed = json.loads(features)
47
- if isinstance(parsed, str):
48
- parsed = json.loads(parsed)
49
- except:
50
- pass
51
-
52
- # Try AST parsing as fallback
53
- if not isinstance(parsed, list):
54
- try:
55
- parsed = ast.literal_eval(features)
56
- if isinstance(parsed, str):
57
- parsed = ast.literal_eval(parsed)
58
- except:
59
- pass
60
-
61
- if isinstance(parsed, list):
62
- features = parsed
63
- else:
64
- if features:
65
- features = [features]
66
- else:
67
- features = []
68
-
69
- if isinstance(features, list):
70
- cleaned = []
71
- for item in features:
72
- if isinstance(item, dict) and "feature" in item:
73
- val = str(item["feature"]).strip().lower()
74
- else:
75
- val = str(item).strip().lower()
76
- if val and val != "nan":
77
- cleaned.append(val)
78
- return list(dict.fromkeys(cleaned))
79
-
80
- return []
81
-
82
- def remove_redundant_features(features):
83
- cleaned = []
84
- seen_words = []
85
-
86
- for feat in features:
87
- feat_words = set(feat.split())
88
- redundant = False
89
-
90
- for existing in seen_words:
91
- overlap = len(feat_words & existing) / max(len(feat_words), 1)
92
- if overlap >= 0.60:
93
- redundant = True
94
- break
95
-
96
- if not redundant:
97
- cleaned.append(feat)
98
- seen_words.append(feat_words)
99
-
100
- return cleaned
101
-
102
- def empty_result(unique_a=None, unique_b=None):
103
- return {
104
- "score": 0.0,
105
- "coverage": 0.0,
106
- "shared_count": 0,
107
- "matches": [],
108
- "unique_a": unique_a or [],
109
- "unique_b": unique_b or []
110
- }
111
-
112
- def encode_features(
113
- features: List[str],
114
- model
115
- ):
116
- import numpy as np
117
- if not features:
118
- return np.array([])
119
-
120
- vectors = model.encode(
121
- features,
122
- convert_to_numpy=True,
123
- normalize_embeddings=True
124
- )
125
- return vectors.astype("float32")
126
-
127
- def compute_feature_similarity(
128
- features_a,
129
- features_b,
130
- model=None,
131
- threshold: float = DEFAULT_THRESHOLD
132
- ) -> Dict[str, Any]:
133
- if model is None:
134
- model = load_feature_model()
135
-
136
- fa = remove_redundant_features(safe_feature_list(features_a))
137
- fb = remove_redundant_features(safe_feature_list(features_b))
138
-
139
- if not fa or not fb:
140
- return empty_result(unique_a=fa, unique_b=fb)
141
-
142
- emb_a = encode_features(fa, model)
143
- emb_b = encode_features(fb, model)
144
-
145
- sim_matrix = cosine_similarity(emb_a, emb_b)
146
-
147
-
148
- row_idx, col_idx = linear_sum_assignment(-sim_matrix)
149
-
150
- matches = []
151
- matched_a = set()
152
- matched_b = set()
153
-
154
- for i, j in zip(row_idx, col_idx):
155
- sim = float(sim_matrix[i, j])
156
- if sim >= threshold:
157
- matches.append({
158
- "feature_a": fa[i],
159
- "feature_b": fb[j],
160
- "score": round(sim, 3)
161
- })
162
- matched_a.add(i)
163
- matched_b.add(j)
164
-
165
- import numpy as np
166
- shared_scores = [m["score"] for m in matches]
167
- mean_similarity = float(np.mean(shared_scores)) if shared_scores else 0.0
168
-
169
- min_len = min(len(fa), len(fb))
170
- coverage = len(matches) / min_len if min_len > 0 else 0.0
171
-
172
- sum_similarity = sum(shared_scores)
173
- final_score = sum_similarity / min_len if min_len > 0 else 0.0
174
-
175
- final_score = min(final_score, 1.0)
176
-
177
- matched_text_a = " ".join([m["feature_a"] for m in matches]).lower()
178
- matched_text_b = " ".join([m["feature_b"] for m in matches]).lower()
179
-
180
- def is_semantically_redundant(feature, matched_text):
181
- words = set(feature.lower().split())
182
- overlap = sum(1 for w in words if w in matched_text)
183
- return (overlap / max(len(words), 1)) >= 0.5
184
-
185
- unique_a = [
186
- fa[i] for i in range(len(fa))
187
- if i not in matched_a and not is_semantically_redundant(fa[i], matched_text_a)
188
- ]
189
-
190
- unique_b = [
191
- fb[j] for j in range(len(fb))
192
- if j not in matched_b and not is_semantically_redundant(fb[j], matched_text_b)
193
- ]
194
-
195
- return {
196
- "score": round(final_score, 4),
197
- "coverage": round(coverage, 4),
198
- "shared_count": len(matches),
199
- "matches": matches,
200
- "unique_a": unique_a,
201
- "unique_b": unique_b
202
- }
203
-
204
- def compare_projects(
205
- df: pd.DataFrame,
206
- idx1: int,
207
- idx2: int,
208
- model=None
209
- ) -> Dict[str, Any]:
210
- if idx1 not in df.index or idx2 not in df.index:
211
- return empty_result()
212
-
213
- f1 = df.loc[idx1, "features"]
214
- f2 = df.loc[idx2, "features"]
215
-
216
- return compute_feature_similarity(f1, f2, model=model)
217
-
218
- def compare_project_against_many(
219
- df: pd.DataFrame,
220
- idx1: int,
221
- indices: List[int],
222
- model=None
223
- ) -> Dict[int, Dict[str, Any]]:
224
- if idx1 not in df.index:
225
- return {}
226
-
227
- f1 = df.loc[idx1, 'features']
228
- results = {}
229
-
230
- for idx2 in indices:
231
- if idx2 in df.index:
232
- f2 = df.loc[idx2, 'features']
233
- results[idx2] = compute_feature_similarity(f1, f2, model=model)
234
-
235
- return results
 
1
+ from typing import List, Dict, Any
2
+ import pandas as pd
3
+ from sentence_transformers import SentenceTransformer
4
+ from scipy.optimize import linear_sum_assignment
5
+ from sklearn.metrics.pairwise import cosine_similarity
6
+ import logging
7
+ from functools import lru_cache
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ MODEL_NAME = "all-mpnet-base-v2"
12
+ SIMILARITY_WEIGHT = 0.70
13
+ COVERAGE_WEIGHT = 0.30
14
+ DEFAULT_THRESHOLD = 0.65
15
+
16
+ @lru_cache(maxsize=1)
17
+ def load_feature_model():
18
+ logger.info(f"Loading feature model: {MODEL_NAME}")
19
+ return SentenceTransformer(MODEL_NAME)
20
+
21
+ def safe_feature_list(features):
22
+ """
23
+ Convert any feature input into clean List[str]
24
+ """
25
+ import numpy as np
26
+ import json
27
+ import ast
28
+
29
+ if features is None:
30
+ return []
31
+
32
+ if isinstance(features, float) and pd.isna(features):
33
+ return []
34
+
35
+ if isinstance(features, np.ndarray):
36
+ features = features.tolist()
37
+
38
+ if isinstance(features, tuple):
39
+ features = list(features)
40
+
41
+ if isinstance(features, str):
42
+ features = features.strip()
43
+ parsed = None
44
+ # Try JSON parsing
45
+ try:
46
+ parsed = json.loads(features)
47
+ if isinstance(parsed, str):
48
+ parsed = json.loads(parsed)
49
+ except:
50
+ pass
51
+
52
+ # Try AST parsing as fallback
53
+ if not isinstance(parsed, list):
54
+ try:
55
+ parsed = ast.literal_eval(features)
56
+ if isinstance(parsed, str):
57
+ parsed = ast.literal_eval(parsed)
58
+ except:
59
+ pass
60
+
61
+ if isinstance(parsed, list):
62
+ features = parsed
63
+ else:
64
+ if features:
65
+ features = [features]
66
+ else:
67
+ features = []
68
+
69
+ if isinstance(features, list):
70
+ cleaned = []
71
+ for item in features:
72
+ if isinstance(item, dict) and "feature" in item:
73
+ val = str(item["feature"]).strip().lower()
74
+ else:
75
+ val = str(item).strip().lower()
76
+ if val and val != "nan":
77
+ cleaned.append(val)
78
+ return list(dict.fromkeys(cleaned))
79
+
80
+ return []
81
+
82
+ def remove_redundant_features(features):
83
+ cleaned = []
84
+ seen_words = []
85
+
86
+ for feat in features:
87
+ feat_words = set(feat.split())
88
+ redundant = False
89
+
90
+ for existing in seen_words:
91
+ overlap = len(feat_words & existing) / max(len(feat_words), 1)
92
+ if overlap >= 0.60:
93
+ redundant = True
94
+ break
95
+
96
+ if not redundant:
97
+ cleaned.append(feat)
98
+ seen_words.append(feat_words)
99
+
100
+ return cleaned
101
+
102
+ def empty_result(unique_a=None, unique_b=None):
103
+ return {
104
+ "score": 0.0,
105
+ "coverage": 0.0,
106
+ "shared_count": 0,
107
+ "matches": [],
108
+ "unique_a": unique_a or [],
109
+ "unique_b": unique_b or []
110
+ }
111
+
112
+ def encode_features(
113
+ features: List[str],
114
+ model
115
+ ):
116
+ import numpy as np
117
+ if not features:
118
+ return np.array([])
119
+
120
+ vectors = model.encode(
121
+ features,
122
+ convert_to_numpy=True,
123
+ normalize_embeddings=True
124
+ )
125
+ return vectors.astype("float32")
126
+
127
+ def compute_feature_similarity(
128
+ features_a,
129
+ features_b,
130
+ model=None,
131
+ threshold: float = DEFAULT_THRESHOLD
132
+ ) -> Dict[str, Any]:
133
+ if model is None:
134
+ model = load_feature_model()
135
+
136
+ fa = remove_redundant_features(safe_feature_list(features_a))
137
+ fb = remove_redundant_features(safe_feature_list(features_b))
138
+
139
+ if not fa or not fb:
140
+ return empty_result(unique_a=fa, unique_b=fb)
141
+
142
+ emb_a = encode_features(fa, model)
143
+ emb_b = encode_features(fb, model)
144
+
145
+ sim_matrix = cosine_similarity(emb_a, emb_b)
146
+
147
+
148
+ row_idx, col_idx = linear_sum_assignment(-sim_matrix)
149
+
150
+ matches = []
151
+ matched_a = set()
152
+ matched_b = set()
153
+
154
+ for i, j in zip(row_idx, col_idx):
155
+ sim = float(sim_matrix[i, j])
156
+ if sim >= threshold:
157
+ matches.append({
158
+ "feature_a": fa[i],
159
+ "feature_b": fb[j],
160
+ "score": round(sim, 3)
161
+ })
162
+ matched_a.add(i)
163
+ matched_b.add(j)
164
+
165
+ import numpy as np
166
+ shared_scores = [m["score"] for m in matches]
167
+ mean_similarity = float(np.mean(shared_scores)) if shared_scores else 0.0
168
+
169
+ min_len = min(len(fa), len(fb))
170
+ coverage = len(matches) / min_len if min_len > 0 else 0.0
171
+
172
+ sum_similarity = sum(shared_scores)
173
+ final_score = sum_similarity / min_len if min_len > 0 else 0.0
174
+
175
+ final_score = min(final_score, 1.0)
176
+
177
+ matched_text_a = " ".join([m["feature_a"] for m in matches]).lower()
178
+ matched_text_b = " ".join([m["feature_b"] for m in matches]).lower()
179
+
180
+ def is_semantically_redundant(feature, matched_text):
181
+ words = set(feature.lower().split())
182
+ overlap = sum(1 for w in words if w in matched_text)
183
+ return (overlap / max(len(words), 1)) >= 0.5
184
+
185
+ unique_a = [
186
+ fa[i] for i in range(len(fa))
187
+ if i not in matched_a and not is_semantically_redundant(fa[i], matched_text_a)
188
+ ]
189
+
190
+ unique_b = [
191
+ fb[j] for j in range(len(fb))
192
+ if j not in matched_b and not is_semantically_redundant(fb[j], matched_text_b)
193
+ ]
194
+
195
+ return {
196
+ "score": round(final_score, 4),
197
+ "coverage": round(coverage, 4),
198
+ "shared_count": len(matches),
199
+ "matches": matches,
200
+ "unique_a": unique_a,
201
+ "unique_b": unique_b
202
+ }
203
+
204
+ def compare_projects(
205
+ df: pd.DataFrame,
206
+ idx1: int,
207
+ idx2: int,
208
+ model=None
209
+ ) -> Dict[str, Any]:
210
+ if idx1 not in df.index or idx2 not in df.index:
211
+ return empty_result()
212
+
213
+ f1 = df.loc[idx1, "features"]
214
+ f2 = df.loc[idx2, "features"]
215
+
216
+ return compute_feature_similarity(f1, f2, model=model)
217
+
218
+ def compare_project_against_many(
219
+ df: pd.DataFrame,
220
+ idx1: int,
221
+ indices: List[int],
222
+ model=None
223
+ ) -> Dict[int, Dict[str, Any]]:
224
+ if idx1 not in df.index:
225
+ return {}
226
+
227
+ f1 = df.loc[idx1, 'features']
228
+ results = {}
229
+
230
+ for idx2 in indices:
231
+ if idx2 in df.index:
232
+ f2 = df.loc[idx2, 'features']
233
+ results[idx2] = compute_feature_similarity(f1, f2, model=model)
234
+
235
+ return results