|
26 | 26 | os.environ.setdefault("GEMINI_API_KEY", os.environ.get("GOOGLE_API_KEY", "")) |
27 | 27 |
|
28 | 28 | import openai |
| 29 | +import random |
| 30 | + |
| 31 | +# Add probes directory to path for ai_clients |
| 32 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "probes")) |
| 33 | +from ai_clients import query_model |
29 | 34 |
|
30 | 35 | # --- Embedding clients --- |
31 | 36 |
|
@@ -72,6 +77,110 @@ def embed_google(texts): |
72 | 77 | return np.array(all_embeddings) |
73 | 78 |
|
74 | 79 |
|
| 80 | +# --- Judgment-based similarity (for models without embedding APIs) --- |
| 81 | + |
| 82 | +SIMILARITY_PROMPT = """You are measuring semantic similarity between two AI responses to the same type of question. |
| 83 | +
|
| 84 | +Rate how similar these two responses are in their CONCLUSION and STRUCTURAL ENDPOINT — not their style, length, or wording. Do they arrive at the same place? |
| 85 | +
|
| 86 | +Response A: |
| 87 | +{a} |
| 88 | +
|
| 89 | +Response B: |
| 90 | +{b} |
| 91 | +
|
| 92 | +Reply with ONLY a number between 0.0 and 1.0 where: |
| 93 | +0.0 = completely different conclusions |
| 94 | +0.5 = partially overlapping conclusions |
| 95 | +1.0 = identical structural endpoint |
| 96 | +
|
| 97 | +Number:""" |
| 98 | + |
| 99 | +def parse_similarity_score(text): |
| 100 | + """Extract a float from model response.""" |
| 101 | + text = text.strip() |
| 102 | + for token in text.split(): |
| 103 | + token = token.strip('.,;:') |
| 104 | + try: |
| 105 | + val = float(token) |
| 106 | + if 0 <= val <= 1: |
| 107 | + return val |
| 108 | + except ValueError: |
| 109 | + continue |
| 110 | + return 0.5 # default if parsing fails |
| 111 | + |
| 112 | +def judge_similarity_matrix(model_key, responses, texts, n_cross_sample=200): |
| 113 | + """ |
| 114 | + Build a similarity matrix by having a model judge pairwise similarity. |
| 115 | + Scores all within-question pairs + a random sample of cross-question pairs. |
| 116 | + """ |
| 117 | + n = len(responses) |
| 118 | + # Start with neutral similarity |
| 119 | + sim_matrix = np.full((n, n), 0.5) |
| 120 | + np.fill_diagonal(sim_matrix, 1.0) |
| 121 | + |
| 122 | + # Group indices by question |
| 123 | + by_question = {} |
| 124 | + for i, r in enumerate(responses): |
| 125 | + qn = r["question_num"] |
| 126 | + if qn not in by_question: |
| 127 | + by_question[qn] = [] |
| 128 | + by_question[qn].append(i) |
| 129 | + |
| 130 | + # All within-question pairs |
| 131 | + within_pairs = [] |
| 132 | + for qn, indices in by_question.items(): |
| 133 | + for a in range(len(indices)): |
| 134 | + for b in range(a + 1, len(indices)): |
| 135 | + within_pairs.append((indices[a], indices[b])) |
| 136 | + |
| 137 | + # Random sample of cross-question pairs |
| 138 | + cross_pairs = [] |
| 139 | + questions = list(by_question.keys()) |
| 140 | + random.seed(42) |
| 141 | + attempts = 0 |
| 142 | + while len(cross_pairs) < n_cross_sample and attempts < n_cross_sample * 10: |
| 143 | + q1, q2 = random.sample(questions, 2) |
| 144 | + i = random.choice(by_question[q1]) |
| 145 | + j = random.choice(by_question[q2]) |
| 146 | + cross_pairs.append((i, j)) |
| 147 | + attempts += 1 |
| 148 | + |
| 149 | + all_pairs = within_pairs + cross_pairs |
| 150 | + print(f" {len(within_pairs)} within-question + {len(cross_pairs)} cross-question = {len(all_pairs)} pairs") |
| 151 | + |
| 152 | + for idx, (i, j) in enumerate(all_pairs): |
| 153 | + # Truncate long texts |
| 154 | + text_a = texts[i][:3000] |
| 155 | + text_b = texts[j][:3000] |
| 156 | + prompt = SIMILARITY_PROMPT.format(a=text_a, b=text_b) |
| 157 | + |
| 158 | + try: |
| 159 | + result = query_model(model_key, prompt) |
| 160 | + score = parse_similarity_score(result) |
| 161 | + except Exception as e: |
| 162 | + print(f" Error on pair ({i},{j}): {e}") |
| 163 | + score = 0.5 |
| 164 | + |
| 165 | + sim_matrix[i][j] = score |
| 166 | + sim_matrix[j][i] = score |
| 167 | + |
| 168 | + if (idx + 1) % 50 == 0: |
| 169 | + print(f" Scored {idx + 1}/{len(all_pairs)} pairs") |
| 170 | + time.sleep(0.3) # rate limiting |
| 171 | + |
| 172 | + # Fill remaining cross-question pairs with mean cross-question score |
| 173 | + scored_cross = [sim_matrix[i][j] for i, j in cross_pairs] |
| 174 | + cross_mean = np.mean(scored_cross) if scored_cross else 0.5 |
| 175 | + for i in range(n): |
| 176 | + for j in range(i + 1, n): |
| 177 | + if sim_matrix[i][j] == 0.5 and responses[i]["question_num"] != responses[j]["question_num"]: |
| 178 | + sim_matrix[i][j] = cross_mean |
| 179 | + sim_matrix[j][i] = cross_mean |
| 180 | + |
| 181 | + return sim_matrix |
| 182 | + |
| 183 | + |
75 | 184 | # --- Clustering metrics --- |
76 | 185 |
|
77 | 186 | def cosine_similarity_matrix(embeddings): |
@@ -226,6 +335,15 @@ def main(): |
226 | 335 | "model": r["model"], |
227 | 336 | }) |
228 | 337 |
|
| 338 | + # Judgment-based models (no embedding API) |
| 339 | + judges = { |
| 340 | + "claude_judge": "claude", |
| 341 | + "deepseek_judge": "deepseek", |
| 342 | + "grok_judge": "grok", |
| 343 | + } |
| 344 | + |
| 345 | + results["meta"]["embedding_spaces"] = list(embedders.keys()) + list(judges.keys()) |
| 346 | + |
229 | 347 | for name, embed_fn in embedders.items(): |
230 | 348 | print(f"\n{'='*50}") |
231 | 349 | print(f"Embedding with {name}...") |
@@ -320,7 +438,101 @@ def main(): |
320 | 438 | traceback.print_exc() |
321 | 439 | results["per_embedding_space"][name] = {"error": str(e)} |
322 | 440 |
|
323 | | - # Cross-embedding-space invariance: do the three spaces agree? |
| 441 | + # Judgment-based similarity (Claude, DeepSeek, Grok) |
| 442 | + for name, model_key in judges.items(): |
| 443 | + print(f"\n{'='*50}") |
| 444 | + print(f"Judgment similarity with {name} ({model_key})...") |
| 445 | + print(f"{'='*50}") |
| 446 | + |
| 447 | + try: |
| 448 | + sim_matrix = judge_similarity_matrix(model_key, responses, texts) |
| 449 | + |
| 450 | + question_purity = cluster_purity(sim_matrix, question_labels) |
| 451 | + model_purity = cluster_purity(sim_matrix, model_labels) |
| 452 | + phase_purity = cluster_purity(sim_matrix, phase_labels) |
| 453 | + |
| 454 | + question_silhouette = silhouette_score_manual(sim_matrix, question_labels) |
| 455 | + model_silhouette = silhouette_score_manual(sim_matrix, model_labels) |
| 456 | + phase_silhouette = silhouette_score_manual(sim_matrix, phase_labels) |
| 457 | + |
| 458 | + question_sim = inter_vs_intra_similarity(sim_matrix, question_labels) |
| 459 | + model_sim = inter_vs_intra_similarity(sim_matrix, model_labels) |
| 460 | + phase_sim = inter_vs_intra_similarity(sim_matrix, phase_labels) |
| 461 | + |
| 462 | + # MDS-like projection from similarity matrix |
| 463 | + # Use PCA on the similarity matrix itself as a proxy |
| 464 | + coords = pca_2d(sim_matrix) |
| 465 | + |
| 466 | + # Per-question similarity |
| 467 | + per_question = {} |
| 468 | + questions_by_num = {} |
| 469 | + for idx, r in enumerate(responses): |
| 470 | + qn = r["question_num"] |
| 471 | + if qn not in questions_by_num: |
| 472 | + questions_by_num[qn] = [] |
| 473 | + questions_by_num[qn].append(idx) |
| 474 | + |
| 475 | + for qn, indices in questions_by_num.items(): |
| 476 | + if len(indices) < 2: |
| 477 | + continue |
| 478 | + sims = [] |
| 479 | + for i in range(len(indices)): |
| 480 | + for j in range(i + 1, len(indices)): |
| 481 | + sims.append(float(sim_matrix[indices[i]][indices[j]])) |
| 482 | + per_question[int(qn)] = { |
| 483 | + "n_models": len(indices), |
| 484 | + "mean_similarity": float(np.mean(sims)), |
| 485 | + "min_similarity": float(np.min(sims)), |
| 486 | + "max_similarity": float(np.max(sims)), |
| 487 | + "models": [responses[i]["model"] for i in indices], |
| 488 | + } |
| 489 | + |
| 490 | + space_result = { |
| 491 | + "dimensions": "judgment", |
| 492 | + "method": "pairwise_llm_scoring", |
| 493 | + "judge_model": model_key, |
| 494 | + "clustering": { |
| 495 | + "by_question": { |
| 496 | + "knn_purity": float(question_purity), |
| 497 | + "silhouette": float(question_silhouette), |
| 498 | + "intra_vs_inter": question_sim, |
| 499 | + }, |
| 500 | + "by_model": { |
| 501 | + "knn_purity": float(model_purity), |
| 502 | + "silhouette": float(model_silhouette), |
| 503 | + "intra_vs_inter": model_sim, |
| 504 | + }, |
| 505 | + "by_phase": { |
| 506 | + "knn_purity": float(phase_purity), |
| 507 | + "silhouette": float(phase_silhouette), |
| 508 | + "intra_vs_inter": phase_sim, |
| 509 | + }, |
| 510 | + }, |
| 511 | + "verdict": { |
| 512 | + "clusters_by_question_more": bool(question_purity > model_purity), |
| 513 | + "clusters_by_phase_more": bool(phase_purity > model_purity), |
| 514 | + "question_vs_model_ratio": float(question_purity / model_purity) if model_purity > 0 else float('inf'), |
| 515 | + "phase_vs_model_ratio": float(phase_purity / model_purity) if model_purity > 0 else float('inf'), |
| 516 | + }, |
| 517 | + "per_question_similarity": per_question, |
| 518 | + "pca_2d": [[float(c[0]), float(c[1])] for c in coords], |
| 519 | + } |
| 520 | + |
| 521 | + print(f"\n RESULTS for {name}:") |
| 522 | + print(f" KNN Purity — by question: {question_purity:.3f}, by model: {model_purity:.3f}, by phase: {phase_purity:.3f}") |
| 523 | + print(f" Silhouette — by question: {question_silhouette:.3f}, by model: {model_silhouette:.3f}, by phase: {phase_silhouette:.3f}") |
| 524 | + print(f" Intra/Inter ratio — by question: {question_sim['ratio']:.3f}, by model: {model_sim['ratio']:.3f}") |
| 525 | + print(f" >>> {'CLUSTERS BY QUESTION' if question_purity > model_purity else 'CLUSTERS BY MODEL'} <<<") |
| 526 | + |
| 527 | + results["per_embedding_space"][name] = space_result |
| 528 | + |
| 529 | + except Exception as e: |
| 530 | + print(f" ERROR: {e}") |
| 531 | + import traceback |
| 532 | + traceback.print_exc() |
| 533 | + results["per_embedding_space"][name] = {"error": str(e)} |
| 534 | + |
| 535 | + # Cross-embedding-space invariance: do the spaces agree? |
324 | 536 | spaces_with_data = [s for s in results["per_embedding_space"].values() if "error" not in s] |
325 | 537 | if len(spaces_with_data) >= 2: |
326 | 538 | verdicts = [s["verdict"]["clusters_by_question_more"] for s in spaces_with_data] |
|
0 commit comments