Mukul Rayana commited on
Commit
d64bbe6
·
1 Parent(s): ce15608

Add ablation Condition C eval - mean alignment 0.40 vs D=0.88

Browse files
Files changed (2) hide show
  1. eval/ablation_results.json +162 -0
  2. eval/run_ablation.py +229 -0
eval/ablation_results.json ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "condition_a_scores": [
3
+ 1,
4
+ 1,
5
+ 1,
6
+ 1,
7
+ 0,
8
+ 0,
9
+ 0,
10
+ 0,
11
+ 0,
12
+ 0,
13
+ 0,
14
+ 1,
15
+ 0,
16
+ 0,
17
+ 0,
18
+ 0,
19
+ 0,
20
+ 1,
21
+ 0,
22
+ 0,
23
+ 0,
24
+ 0,
25
+ 0,
26
+ 0,
27
+ 0,
28
+ 0,
29
+ 0,
30
+ 0,
31
+ 0,
32
+ 0,
33
+ 0,
34
+ 0,
35
+ 1,
36
+ 0,
37
+ 0,
38
+ 1,
39
+ 1,
40
+ 1,
41
+ 1,
42
+ 0,
43
+ 0,
44
+ 1,
45
+ 1,
46
+ 0,
47
+ 0,
48
+ 1,
49
+ 0,
50
+ 1,
51
+ 0,
52
+ 0
53
+ ],
54
+ "condition_c_scores": [
55
+ 0,
56
+ 0,
57
+ 0,
58
+ 1,
59
+ 0,
60
+ 0,
61
+ 0,
62
+ 0,
63
+ 0,
64
+ 0,
65
+ 0,
66
+ 0,
67
+ 0,
68
+ 1,
69
+ 0,
70
+ 0,
71
+ 0,
72
+ 0,
73
+ 0,
74
+ 0,
75
+ 0,
76
+ 0,
77
+ 1,
78
+ 0,
79
+ 0,
80
+ 0,
81
+ 0,
82
+ 0,
83
+ 0,
84
+ 0,
85
+ 1,
86
+ 1,
87
+ 1,
88
+ 0,
89
+ 0,
90
+ 1,
91
+ 1,
92
+ 1,
93
+ 1,
94
+ 0,
95
+ 1,
96
+ 1,
97
+ 1,
98
+ 1,
99
+ 1,
100
+ 1,
101
+ 1,
102
+ 1,
103
+ 1,
104
+ 1
105
+ ],
106
+ "condition_d_scores": [
107
+ 1,
108
+ 1,
109
+ 1,
110
+ 1,
111
+ 1,
112
+ 1,
113
+ 1,
114
+ 1,
115
+ 1,
116
+ 1,
117
+ 0,
118
+ 1,
119
+ 1,
120
+ 1,
121
+ 1,
122
+ 1,
123
+ 1,
124
+ 1,
125
+ 0,
126
+ 0,
127
+ 1,
128
+ 1,
129
+ 1,
130
+ 1,
131
+ 1,
132
+ 1,
133
+ 1,
134
+ 0,
135
+ 1,
136
+ 0,
137
+ 1,
138
+ 1,
139
+ 1,
140
+ 1,
141
+ 0,
142
+ 1,
143
+ 1,
144
+ 1,
145
+ 1,
146
+ 1,
147
+ 1,
148
+ 1,
149
+ 1,
150
+ 1,
151
+ 1,
152
+ 1,
153
+ 1,
154
+ 1,
155
+ 1,
156
+ 1
157
+ ],
158
+ "condition_a_mean": 0.3,
159
+ "condition_c_mean": 0.4,
160
+ "condition_d_mean": 0.88,
161
+ "n": 50
162
+ }
eval/run_ablation.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval/run_ablation.py
3
+ Ablation study: compare Condition A (BM25), C (Dense RAG no emotion), D (Full EmpathRAG).
4
+ Computes Condition C as TRUE no-emotion-conditioning ablation:
5
+ - No emotion query rewriting (raw user_message goes to FAISS)
6
+ - No emotion match bonus in re-ranking (safety_score only)
7
+ Loads Conditions A and D from eval/wilcoxon_results.json.
8
+ """
9
+
10
+ import sys, json, types
11
+ sys.path.insert(0, "src")
12
+ sys.path.insert(0, ".")
13
+ sys.path.insert(0, "eval")
14
+
15
+ import numpy as np
16
+ import sqlite3
17
+ import torch
18
+ import time
19
+ from pipeline.pipeline import EmpathRAGPipeline, SAFE_RESPONSE, LABEL_NAMES
20
+ from pipeline.query_router import route_query
21
+
22
+ PROMPTS_PATH = "eval/test_prompts.json"
23
+ WILCOXON_PATH = "eval/wilcoxon_results.json"
24
+ RESULTS_PATH = "eval/ablation_results.json"
25
+
26
+
27
+ def add_condition_c_methods(pipeline):
28
+ """
29
+ Adds two methods to pipeline instance for Condition C ablation:
30
+ 1. _retrieve_no_emotion: retrieval with no emotion match bonus
31
+ 2. run_condition_c: full pipeline run with raw user_message (no query rewriting)
32
+ """
33
+
34
+ def _retrieve_no_emotion(self, query: str, emotion_label: int) -> list[str]:
35
+ """
36
+ Encodes query on GPU, searches FAISS, filters via SQLite.
37
+ Returns top_k chunk texts ranked by SAFETY SCORE ONLY (no emotion bonus).
38
+ GPU usage: ~440 MB during encode, freed before returning.
39
+ """
40
+ # Move encoder to GPU for this call only
41
+ self.encoder.to("cuda")
42
+ q_vec = self.encoder.encode(
43
+ [query],
44
+ normalize_embeddings=True,
45
+ convert_to_numpy=True,
46
+ )
47
+ # Immediately offload back to CPU
48
+ self.encoder.to("cpu")
49
+ torch.cuda.empty_cache()
50
+
51
+ # Search wider than top_k so we have room to re-rank
52
+ distances, ids = self.faiss_index.search(
53
+ q_vec.astype(np.float32), self.top_k * 3
54
+ )
55
+ candidate_ids = [int(i) for i in ids[0] if i >= 0]
56
+
57
+ if not candidate_ids:
58
+ return []
59
+
60
+ # Fetch metadata from SQLite
61
+ placeholders = ",".join("?" * len(candidate_ids))
62
+ conn = sqlite3.connect(self.db_path)
63
+ rows = conn.execute(
64
+ f"SELECT id, text, emotion_label, safety_score FROM chunks "
65
+ f"WHERE id IN ({placeholders})",
66
+ candidate_ids,
67
+ ).fetchall()
68
+ conn.close()
69
+
70
+ # Re-rank: ONLY by safety_score (no emotion match bonus)
71
+ def _score(row):
72
+ _, _, chunk_emotion, safety = row
73
+ return safety # No match_bonus - pure semantic similarity + safety
74
+
75
+ rows_sorted = sorted(rows, key=_score, reverse=True)[:self.top_k]
76
+ return [r[1] for r in rows_sorted]
77
+
78
+ def run_condition_c(self, user_message: str) -> dict:
79
+ """
80
+ Condition C: No emotion-conditioned retrieval.
81
+ Exact copy of real run() with two changes:
82
+ 1. guardrail.check has skip_ig=True
83
+ 2. Stage 4 uses _retrieve_no_emotion(user_message) instead of _retrieve(routed_query)
84
+ """
85
+ latency = {}
86
+ token_count = len(user_message.split())
87
+ t0 = time.perf_counter()
88
+ emotion_label = self._classify_emotion(user_message)
89
+ latency["emotion_ms"] = round((time.perf_counter() - t0) * 1000)
90
+ t0 = time.perf_counter()
91
+ is_crisis, confidence, ig_highlights = self.guardrail.check(
92
+ user_message, threshold=self.guardrail_threshold, skip_ig=True
93
+ )
94
+ latency["guardrail_ms"] = round((time.perf_counter() - t0) * 1000)
95
+ self.tracker.update(emotion_label, token_count)
96
+ trajectory = self.tracker.trajectory()
97
+ if is_crisis:
98
+ return {
99
+ "response": SAFE_RESPONSE,
100
+ "emotion": emotion_label,
101
+ "emotion_name": LABEL_NAMES[emotion_label],
102
+ "trajectory": trajectory,
103
+ "crisis": True,
104
+ "crisis_confidence": confidence,
105
+ "ig_highlights": ig_highlights,
106
+ "retrieved_chunks": [],
107
+ "latency_ms": latency,
108
+ }
109
+ t0 = time.perf_counter()
110
+ routed_query = route_query(user_message, emotion_label, trajectory)
111
+ latency["router_ms"] = round((time.perf_counter() - t0) * 1000)
112
+ t0 = time.perf_counter()
113
+ chunks = self._retrieve_no_emotion(user_message, emotion_label)
114
+ latency["retrieval_ms"] = round((time.perf_counter() - t0) * 1000)
115
+ t0 = time.perf_counter()
116
+ response = self._generate(user_message, chunks)
117
+ latency["generation_ms"] = round((time.perf_counter() - t0) * 1000)
118
+ latency["total_ms"] = sum(latency.values())
119
+ return {
120
+ "response": response,
121
+ "emotion": emotion_label,
122
+ "emotion_name": LABEL_NAMES[emotion_label],
123
+ "trajectory": trajectory,
124
+ "crisis": False,
125
+ "crisis_confidence": 0.0,
126
+ "ig_highlights": [],
127
+ "retrieved_chunks": chunks,
128
+ "latency_ms": latency,
129
+ }
130
+
131
+ # Bind methods to pipeline instance
132
+ pipeline._retrieve_no_emotion = types.MethodType(_retrieve_no_emotion, pipeline)
133
+ pipeline.run_condition_c = types.MethodType(run_condition_c, pipeline)
134
+
135
+
136
+ def compute_alignment_scores(pipeline, prompts, use_condition_c=False):
137
+ """
138
+ For each non-crisis prompt, compute binary alignment score:
139
+ 1 if emotion(query) == emotion(top retrieved chunk), else 0.
140
+ """
141
+ scores = []
142
+ for i, prompt in enumerate(prompts, 1):
143
+ if use_condition_c:
144
+ result = pipeline.run_condition_c(prompt["text"])
145
+ else:
146
+ result = pipeline.run(prompt["text"])
147
+
148
+ if result["crisis"]:
149
+ print(f" Prompt {i:02d}/50: CRISIS (guardrail fired unexpectedly), alignment=0")
150
+ scores.append(0)
151
+ continue
152
+
153
+ if not result["retrieved_chunks"]:
154
+ print(f" WARNING: Prompt {i:02d}/50: NO CHUNKS retrieved, alignment=0")
155
+ scores.append(0)
156
+ continue
157
+
158
+ q_emotion = result["emotion"]
159
+ top_chunk = result["retrieved_chunks"][0]
160
+ chunk_emotion = pipeline._classify_emotion(top_chunk)
161
+ alignment = int(q_emotion == chunk_emotion)
162
+ scores.append(alignment)
163
+ print(f" Prompt {i:02d}/50: alignment={alignment} (query={q_emotion}, chunk={chunk_emotion})")
164
+
165
+ return scores
166
+
167
+
168
+ def run_ablation_eval():
169
+ # Load test prompts
170
+ with open(PROMPTS_PATH) as f:
171
+ prompts = json.load(f)
172
+
173
+ # Load Conditions A and D from Wilcoxon results
174
+ print("Loading Conditions A and D from wilcoxon_results.json...")
175
+ with open(WILCOXON_PATH) as f:
176
+ wilcoxon = json.load(f)
177
+
178
+ scores_a = wilcoxon["condition_a_scores"]
179
+ scores_d = wilcoxon["condition_d_scores"]
180
+ print(f" Condition A (BM25): {len(scores_a)} scores loaded")
181
+ print(f" Condition D (Full EmpathRAG): {len(scores_d)} scores loaded")
182
+
183
+ # Compute Condition C: Dense RAG without emotion conditioning
184
+ print("\nCondition C - Dense RAG without emotion conditioning")
185
+ print("Initializing pipeline (use_real_guardrail=False)...")
186
+ pipeline = EmpathRAGPipeline(use_real_guardrail=False, guardrail_threshold=0.5)
187
+
188
+ # Add Condition C methods
189
+ print("Adding Condition C methods (no query rewriting, no emotion bonus)...")
190
+ add_condition_c_methods(pipeline)
191
+
192
+ print("Computing Condition C alignment scores...")
193
+ scores_c = compute_alignment_scores(pipeline, prompts, use_condition_c=True)
194
+
195
+ # Compute means
196
+ mean_a = sum(scores_a) / len(scores_a)
197
+ mean_c = sum(scores_c) / len(scores_c)
198
+ mean_d = sum(scores_d) / len(scores_d)
199
+
200
+ # Print summary table
201
+ print("\n" + "="*60)
202
+ print("ABLATION STUDY RESULTS")
203
+ print("="*60)
204
+ print(f"{'Condition':<30} | {'Mean Alignment':>15} | {'N':>3}")
205
+ print("-"*60)
206
+ print(f"{'A (BM25 baseline)':<30} | {mean_a:>15.4f} | {len(scores_a):>3}")
207
+ print(f"{'C (Dense RAG, no emotion)':<30} | {mean_c:>15.4f} | {len(scores_c):>3}")
208
+ print(f"{'D (Full EmpathRAG)':<30} | {mean_d:>15.4f} | {len(scores_d):>3}")
209
+ print("="*60)
210
+
211
+ # Save results
212
+ output = {
213
+ "condition_a_scores": scores_a,
214
+ "condition_c_scores": scores_c,
215
+ "condition_d_scores": scores_d,
216
+ "condition_a_mean": round(mean_a, 4),
217
+ "condition_c_mean": round(mean_c, 4),
218
+ "condition_d_mean": round(mean_d, 4),
219
+ "n": len(prompts),
220
+ }
221
+
222
+ with open(RESULTS_PATH, "w") as f:
223
+ json.dump(output, f, indent=2)
224
+
225
+ print(f"\nResults saved to {RESULTS_PATH}")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ run_ablation_eval()