-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate.py
More file actions
232 lines (186 loc) · 6.93 KB
/
Copy pathvalidate.py
File metadata and controls
232 lines (186 loc) · 6.93 KB
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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
"""Reproduce the core detection experiment from the paper.
Tests whether cosine similarity between query and response embeddings
can distinguish grounded from confabulated responses.
Requirements:
pip install sentence-transformers pandas numpy scikit-learn
Usage:
python scripts/validate.py
python scripts/validate.py --model all-mpnet-base-v2
python scripts/validate.py --model all-MiniLM-L6-v2 --domain finance
python scripts/validate.py --all-models
"""
from __future__ import annotations
import argparse
import functools
import warnings
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import numpy.typing as npt
import pandas as pd
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
warnings.filterwarnings("ignore", category=FutureWarning)
DATA_PATH: Path = Path(__file__).parent.parent / "data" / "human_confabulations.csv"
MODELS: list[str] = [
"all-MiniLM-L6-v2",
"all-mpnet-base-v2",
"BAAI/bge-small-en-v1.5",
"thenlper/gte-small",
]
# Type alias for embedding matrices (n_samples, embedding_dim).
Embeddings = npt.NDArray[np.float32]
@dataclass(frozen=True)
class DetectionMetrics:
"""Results of a cosine-similarity detection experiment."""
accuracy: float
mean_delta: float
paired_similarity: float
n: int
@property
def wilson_ci(self) -> tuple[float, float]:
"""Wilson score 95 % confidence interval for detection accuracy."""
z: float = 1.96
denom = 1 + z**2 / self.n
centre = (self.accuracy + z**2 / (2 * self.n)) / denom
spread = (
z
* np.sqrt(
(self.accuracy * (1 - self.accuracy) + z**2 / (4 * self.n))
/ self.n
)
/ denom
)
return max(0.0, centre - spread), min(1.0, centre + spread)
def _pairwise_cosines(a: Embeddings, b: Embeddings) -> npt.NDArray[np.float64]:
"""Row-wise cosine similarity between two aligned embedding matrices."""
# Normalise to unit vectors, then dot product per row.
a_norm = a / np.linalg.norm(a, axis=1, keepdims=True)
b_norm = b / np.linalg.norm(b, axis=1, keepdims=True)
return np.sum(a_norm * b_norm, axis=1)
def compute_metrics(
questions: Embeddings,
grounded: Embeddings,
fabricated: Embeddings,
) -> DetectionMetrics:
"""Compute detection accuracy, paired similarity, and mean delta.
Detection succeeds for a pair when cos(question, grounded) exceeds
cos(question, fabricated). Random baseline is 50 %.
"""
cos_qg = _pairwise_cosines(questions, grounded)
cos_qf = _pairwise_cosines(questions, fabricated)
cos_gf = _pairwise_cosines(grounded, fabricated)
return DetectionMetrics(
accuracy=float(np.mean(cos_qg > cos_qf)),
mean_delta=float(np.mean(cos_qg - cos_qf)),
paired_similarity=float(np.mean(cos_gf)),
n=len(questions),
)
@functools.lru_cache(maxsize=4)
def _load_model(model_name: str) -> SentenceTransformer:
"""Load and cache a sentence-transformer model."""
return SentenceTransformer(model_name)
def encode_dataset(
model: SentenceTransformer,
df: pd.DataFrame,
) -> tuple[Embeddings, Embeddings, Embeddings]:
"""Encode questions, grounded responses, and fabricated responses."""
questions = model.encode(df["question"].tolist(), show_progress_bar=False)
grounded = model.encode(df["grounded_response"].tolist(), show_progress_bar=False)
fabricated = model.encode(df["fabricated_response"].tolist(), show_progress_bar=False)
return questions, grounded, fabricated
def _print_header(model_name: str, n_pairs: int, domain: str | None) -> None:
"""Print experiment header."""
print(f"\nModel: {model_name}")
print(f"Pairs: {n_pairs}")
if domain:
print(f"Domain: {domain}")
print("-" * 66)
def _print_metrics(metrics: DetectionMetrics) -> None:
"""Print aggregate metrics with confidence interval."""
lo, hi = metrics.wilson_ci
print(
f"Detection accuracy: {metrics.accuracy:.1%} "
f"[95% CI: {lo:.1%}, {hi:.1%}]"
)
print(f"Mean delta: {metrics.mean_delta:.4f}")
print(f"Paired similarity: {metrics.paired_similarity:.4f}")
def _print_domain_table(
df: pd.DataFrame,
questions: Embeddings,
grounded: Embeddings,
fabricated: Embeddings,
) -> None:
"""Print per-domain detection breakdown."""
print(
f"\n{'Domain':<20s} {'n':>4s} {'Accuracy':>10s} "
f"{'95% CI':>16s} {'Paired sim':>12s}"
)
print("-" * 66)
for domain in sorted(df["domain"].unique()):
idx: npt.NDArray[np.bool_] = (df["domain"] == domain).values
m = compute_metrics(questions[idx], grounded[idx], fabricated[idx])
lo, hi = m.wilson_ci
print(
f"{domain:<20s} {m.n:>4d} {m.accuracy:>10.1%} "
f"[{lo:.1%}, {hi:.1%}] {m.paired_similarity:>10.4f}"
)
def run_experiment(
model_name: str,
df: pd.DataFrame,
*,
domain: str | None = None,
) -> None:
"""Run the detection experiment for one embedding model.
Args:
model_name: HuggingFace model identifier.
df: Benchmark dataframe with columns ``question``,
``grounded_response``, ``fabricated_response``, ``domain``.
domain: If provided, restrict evaluation to this single domain.
"""
if domain is not None:
df = df[df["domain"] == domain].copy()
if df.empty:
print(f"No data for domain '{domain}'")
return
_print_header(model_name, len(df), domain)
model = _load_model(model_name)
questions, grounded, fabricated = encode_dataset(model, df)
_print_metrics(compute_metrics(questions, grounded, fabricated))
if domain is None:
_print_domain_table(df, questions, grounded, fabricated)
def _parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Reproduce detection experiment on human confabulations",
)
parser.add_argument(
"--model",
type=str,
default="all-MiniLM-L6-v2",
help=f"Embedding model. Options: {', '.join(MODELS)}",
)
parser.add_argument(
"--domain",
type=str,
default=None,
help="Filter to a specific domain (e.g., finance, medical)",
)
parser.add_argument(
"--all-models",
action="store_true",
help="Run experiment across all four models",
)
return parser.parse_args()
def main() -> None:
"""Entry point for the detection experiment."""
args = _parse_args()
df: pd.DataFrame = pd.read_csv(DATA_PATH)
print("=" * 66)
print("Human-Confabulated Hallucination Benchmark — Detection Experiment")
print("=" * 66)
models = MODELS if args.all_models else [args.model]
for model_name in models:
run_experiment(model_name, df, domain=args.domain)
if __name__ == "__main__":
main()