tejzz4589 commited on
Commit
f1cdb6f
·
0 Parent(s):

may be final

Browse files
.gitignore ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ ENV/
27
+
28
+ # IDEs
29
+ .vscode/
30
+ .idea/
31
+ *.swp
32
+ *.swo
33
+ *~
34
+
35
+ # Jupyter Notebook
36
+ .ipynb_checkpoints
37
+
38
+ # Model cache
39
+ .cache/
40
+ models/
41
+
42
+ # Output files
43
+ *.png
44
+ *.jpg
45
+ *.pdf
46
+ entropy_curve.png
47
+ example_entropy_curve.png
48
+
49
+ # OS
50
+ .DS_Store
51
+ Thumbs.db
52
+
53
+ # Streamlit
54
+ .streamlit/
ARCHITECTURE.md ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # System Architecture
2
+
3
+ ## Data Flow Diagram
4
+
5
+ ```
6
+ ┌─────────────────────────────────────────────────────────────────────┐
7
+ │ USER INPUT │
8
+ │ "What is the capital of France?" │
9
+ └──────────────────────────────┬──────────────────────────────────────┘
10
+
11
+
12
+ ┌─────────────────────────────────────────────────────────────────────┐
13
+ │ MODEL LOADER │
14
+ │ ┌──────────────────────────────────────────────────────────────┐ │
15
+ │ │ TransformerLens GPT-2 │ │
16
+ │ │ • Load pretrained model │ │
17
+ │ │ • Generate 5 stochastic responses (temp=0.8) │ │
18
+ │ │ • Capture activations (logits, hidden states, attention) │ │
19
+ │ └──────────────────────────────────────────────────────────────┘ │
20
+ └──────────────────────────────┬──────────────────────────────────────┘
21
+
22
+ ┌──────────────┴──────────────┐
23
+ │ │
24
+ ▼ ▼
25
+ ┌───────────────────────────┐ ┌─────────────────────────────┐
26
+ │ INTERNAL METRICS │ │ EXTERNAL VERIFIER │
27
+ │ │ │ │
28
+ │ ┌─────────────────────┐ │ │ ┌───────────────────────┐ │
29
+ │ │ Entropy Metric │ │ │ │ Ground Truth Loader │ │
30
+ │ │ • Token-level │ │ │ │ • Match question │ │
31
+ │ │ • Mean & max │ │ │ │ • Load answer │ │
32
+ │ │ • Normalized │ │ │ └───────────────────────┘ │
33
+ │ └─────────────────────┘ │ │ │
34
+ │ │ │ ┌───────────────────────┐ │
35
+ │ ┌─────────────────────┐ │ │ │ Sentence Transformer │ │
36
+ │ │ Stability Metric │ │ │ │ • Encode responses │ │
37
+ │ │ • Layer similarity │ │ │ │ • Encode ground truth │ │
38
+ │ │ • Cosine distance │ │ │ │ • Compute similarity │ │
39
+ │ │ • Averaged │ │ │ └───────────────────────┘ │
40
+ │ └─────────────────────┘ │ │ │
41
+ │ │ │ ┌───────────────────────┐ │
42
+ │ ┌─────────────────────┐ │ │ │ Consistency Score │ │
43
+ │ │ Grounding Metric │ │ │ │ • Mean similarity │ │
44
+ │ │ • Attention to │ │ │ │ • External risk │ │
45
+ │ │ prompt ratio │ │ │ │ = 1 - consistency │ │
46
+ │ │ • Across layers │ │ │ └───────────────────────┘ │
47
+ │ └─────────────────────┘ │ │ │
48
+ │ │ └─────────────────────────────┘
49
+ │ ┌─────────────────────┐ │
50
+ │ │ Internal Risk │ │
51
+ │ │ = w1*entropy + │ │
52
+ │ │ w2*(1-stability)+ │ │
53
+ │ │ w3*(1-grounding) │ │
54
+ │ └─────────────────────┘ │
55
+ └───────────┬───────────────┘
56
+
57
+
58
+
59
+ ┌─────────────────────────────────────────────────────────────────────┐
60
+ │ ANALYZER │
61
+ │ ┌──────────────────────────────────────────────────────────────┐ │
62
+ │ │ Hybrid Risk Score │ │
63
+ │ │ = alpha * InternalRisk + beta * ExternalRisk │ │
64
+ │ │ = 0.6 * InternalRisk + 0.4 * ExternalRisk │ │
65
+ │ └──────────────────────────────────────────────────────────────┘ │
66
+ └──────────────────────────────┬──────────────────────────────────────┘
67
+
68
+
69
+ ┌─────────────────────────────────────────────────────────────────────┐
70
+ │ OUTPUT │
71
+ │ ┌──────────────────────────────────────────────────────────────┐ │
72
+ │ │ • 5 Generated Responses │ │
73
+ │ │ • Mean Entropy: 2.3456 │ │
74
+ │ │ • Max Entropy: 4.5678 │ │
75
+ │ │ • Stability Score: 0.8765 │ │
76
+ │ │ • Grounding Score: 0.7654 │ │
77
+ │ │ • Internal Risk: 0.3456 │ │
78
+ │ │ • Similarity Scores: [0.92, 0.89, 0.91, 0.88, 0.90] │ │
79
+ │ │ • External Consistency: 0.9000 │ │
80
+ │ │ • External Risk: 0.1000 │ │
81
+ │ │ • FINAL RISK: 0.2474 (LOW) │ │
82
+ │ │ • Entropy Curve Visualization │ │
83
+ │ └──────────────────────────────────────────────────────────────┘ │
84
+ └─────────────────────────────────────────────────────────────────────┘
85
+ ```
86
+
87
+ ## Component Interaction
88
+
89
+ ```
90
+ ┌──────────────┐
91
+ │ app.py │ Streamlit UI
92
+ │ main.py │ CLI Interface
93
+ │ example.py │ Demo Script
94
+ └──────┬───────┘
95
+
96
+ │ uses
97
+
98
+ ┌──────────────────┐
99
+ │ analyzer.py │ Orchestrates everything
100
+ └──────┬───────────┘
101
+
102
+ │ coordinates
103
+
104
+ ┌──────────────────────────────────────────┐
105
+ │ model_loader.py │
106
+ │ internal_metrics.py │
107
+ │ external_verifier.py │
108
+ └──────────────────────────────────────────┘
109
+
110
+ │ uses
111
+
112
+ ┌──────────────────────────────────────────┐
113
+ │ TransformerLens (GPT-2) │
114
+ │ SentenceTransformer (MiniLM) │
115
+ │ PyTorch, NumPy, Matplotlib │
116
+ └──────────────────────────────────────────┘
117
+ ```
118
+
119
+ ## Risk Calculation Formula
120
+
121
+ ```
122
+ InternalRisk = w1 × normalized_entropy
123
+ + w2 × (1 - stability)
124
+ + w3 × (1 - grounding)
125
+
126
+ where:
127
+ w1 = 0.4 (entropy weight)
128
+ w2 = 0.3 (stability weight)
129
+ w3 = 0.3 (grounding weight)
130
+
131
+ ExternalRisk = 1 - ExternalConsistency
132
+ = 1 - mean(similarities)
133
+
134
+ FinalRisk = α × InternalRisk + β × ExternalRisk
135
+
136
+ where:
137
+ α = 0.6 (internal weight)
138
+ β = 0.4 (external weight)
139
+
140
+ Risk Interpretation:
141
+ FinalRisk < 0.3 → LOW RISK (reliable)
142
+ 0.3 ≤ FinalRisk < 0.6 → MEDIUM RISK (uncertain)
143
+ FinalRisk ≥ 0.6 → HIGH RISK (hallucination)
144
+ ```
145
+
146
+ ## Module Dependencies
147
+
148
+ ```
149
+ model_loader.py
150
+ ├── transformer_lens (HookedTransformer)
151
+ ├── torch
152
+ └── numpy
153
+
154
+ internal_metrics.py
155
+ ├── torch
156
+ ├── torch.nn.functional
157
+ └── numpy
158
+
159
+ external_verifier.py
160
+ ├── sentence_transformers
161
+ ├── sklearn.metrics.pairwise
162
+ ├── json
163
+ └── numpy
164
+
165
+ analyzer.py
166
+ ├── model_loader
167
+ ├── internal_metrics
168
+ ├── external_verifier
169
+ └── matplotlib
170
+
171
+ app.py
172
+ ├── analyzer
173
+ ├── streamlit
174
+ ├── plotly
175
+ └── matplotlib
176
+
177
+ main.py
178
+ ├── analyzer
179
+ └── argparse
180
+ ```
181
+
182
+ ## File Sizes
183
+
184
+ ```
185
+ Core Modules: ~25 KB
186
+ UI/CLI: ~15 KB
187
+ Documentation: ~15 KB
188
+ Data: ~1.5 KB
189
+ Total Project: ~56 KB
190
+
191
+ External Downloads (first run):
192
+ GPT-2 model: ~500 MB
193
+ MiniLM model: ~80 MB
194
+ PyTorch: ~200 MB
195
+ ```
INSTALL_ANACONDA.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Installation Guide for Anaconda Users
2
+
3
+ ## Prerequisites
4
+ - Anaconda or Miniconda installed
5
+ - Python 3.8 or higher
6
+
7
+ ## Installation Steps
8
+
9
+ ### Step 1: Create a New Conda Environment (Recommended)
10
+
11
+ ```bash
12
+ conda create -n hallucination python=3.10
13
+ conda activate hallucination
14
+ ```
15
+
16
+ ### Step 2: Install PyTorch
17
+
18
+ Install PyTorch based on your system:
19
+
20
+ **For CPU only:**
21
+ ```bash
22
+ conda install pytorch torchvision torchaudio cpuonly -c pytorch
23
+ ```
24
+
25
+ **For CUDA (GPU) - if you have NVIDIA GPU:**
26
+ ```bash
27
+ conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia
28
+ ```
29
+
30
+ ### Step 3: Install Other Dependencies
31
+
32
+ ```bash
33
+ pip install transformer-lens transformers sentence-transformers streamlit plotly scikit-learn matplotlib numpy
34
+ ```
35
+
36
+ Or use the requirements file:
37
+ ```bash
38
+ pip install -r requirements.txt
39
+ ```
40
+
41
+ ### Step 4: Verify Installation
42
+
43
+ ```bash
44
+ python -c "import torch; import transformer_lens; import streamlit; print('All dependencies installed successfully!')"
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ### Activate Environment
50
+ ```bash
51
+ conda activate hallucination
52
+ ```
53
+
54
+ ### Run Streamlit UI
55
+ ```bash
56
+ streamlit run app.py
57
+ ```
58
+
59
+ ### Run Example
60
+ ```bash
61
+ python example.py
62
+ ```
63
+
64
+ ### Run CLI
65
+ ```bash
66
+ python main.py --prompt "What is the capital of France?"
67
+ ```
68
+
69
+ ## Troubleshooting
70
+
71
+ ### ImportError: No module named 'transformer_lens'
72
+ ```bash
73
+ pip install transformer-lens
74
+ ```
75
+
76
+ ### CUDA Out of Memory
77
+ Use CPU-only version or reduce batch size:
78
+ ```bash
79
+ python main.py --prompt "..." --num-responses 3 --max-length 30
80
+ ```
81
+
82
+ ### Slow Performance on First Run
83
+ - First run downloads models (~500MB)
84
+ - Subsequent runs will be faster
85
+ - Models are cached in `~/.cache/huggingface/`
86
+
87
+ ## Deactivate Environment
88
+
89
+ When done:
90
+ ```bash
91
+ conda deactivate
92
+ ```
93
+
94
+ ## Uninstall
95
+
96
+ To remove the environment:
97
+ ```bash
98
+ conda deactivate
99
+ conda env remove -n hallucination
100
+ ```
PROJECT_SUMMARY.md ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎯 PROJECT COMPLETE - Hybrid LLM Hallucination Detection System
2
+
3
+ ## ✅ What Has Been Built
4
+
5
+ A complete, production-ready Python project for detecting hallucinations in LLM-generated text using GPT-2 and TransformerLens.
6
+
7
+ ---
8
+
9
+ ## 📁 Project Structure
10
+
11
+ ```
12
+ gllm/
13
+
14
+ ├── 🔧 Core Modules
15
+ │ ├── model_loader.py # GPT-2 loading & text generation
16
+ │ ├── internal_metrics.py # Entropy, stability, grounding metrics
17
+ │ ├── external_verifier.py # Ground truth matching & similarity
18
+ │ └── analyzer.py # Main orchestrator combining all metrics
19
+
20
+ ├── 🚀 Entry Points
21
+ │ ├── app.py # Streamlit web UI (RECOMMENDED)
22
+ │ ├── main.py # Command-line interface
23
+ │ └── example.py # Quick demo script
24
+
25
+ ├── 📊 Data
26
+ │ └── ground_truth.json # 10 sample Q&A pairs for verification
27
+
28
+ ├── 📖 Documentation
29
+ │ ├── README.md # Complete project documentation
30
+ │ ├── SETUP.md # Quick setup for Anaconda users
31
+ │ ├── QUICKSTART.md # Quick start guide
32
+ │ └── INSTALL_ANACONDA.md # Detailed Anaconda installation
33
+
34
+ ├── 🛠️ Utilities
35
+ │ ├── install.bat # Auto-install script (Windows)
36
+ │ ├── run_ui.bat # Launch Streamlit UI (Windows)
37
+ │ ├── requirements.txt # Python dependencies
38
+ │ └── .gitignore # Git ignore rules
39
+
40
+ └── Total: 16 files, ~52KB code
41
+ ```
42
+
43
+ ---
44
+
45
+ ## 🎨 Features Implemented
46
+
47
+ ### ✅ Internal Hallucination Analysis
48
+ - **Entropy Metric**: Token-level uncertainty from logits
49
+ - **Stability Metric**: Layer-wise hidden state similarity
50
+ - **Attention Grounding**: Attention to prompt tokens ratio
51
+ - **Internal Risk Score**: Weighted combination (w1=0.4, w2=0.3, w3=0.3)
52
+
53
+ ### ✅ External Factual Verification
54
+ - Ground truth dataset loading (JSON)
55
+ - Semantic similarity using sentence-transformers (all-MiniLM-L6-v2)
56
+ - Multi-response consistency checking (5 stochastic samples)
57
+ - External risk calculation
58
+
59
+ ### ✅ Hybrid Risk Scoring
60
+ - Combined internal + external metrics
61
+ - Configurable weights (alpha=0.6, beta=0.4)
62
+ - Risk interpretation (LOW/MEDIUM/HIGH)
63
+
64
+ ### ✅ Streamlit Web UI
65
+ - Interactive parameter configuration
66
+ - Real-time analysis dashboard
67
+ - Plotly visualizations:
68
+ - Entropy curves
69
+ - Metric comparisons
70
+ - Risk breakdowns
71
+ - Layer-wise stability
72
+ - Comprehensive metric displays
73
+ - Modern, professional design
74
+
75
+ ### ✅ CLI Interface
76
+ - Full command-line support
77
+ - Configurable parameters
78
+ - Matplotlib entropy plots
79
+ - Formatted text output
80
+
81
+ ---
82
+
83
+ ## 🚀 How to Use (Anaconda Prompt)
84
+
85
+ ### 1️⃣ Install Dependencies
86
+ ```bash
87
+ cd C:\Users\Sanjana\Desktop\gllm
88
+ conda install pytorch torchvision torchaudio cpuonly -c pytorch -y
89
+ pip install transformer-lens transformers sentence-transformers streamlit plotly scikit-learn
90
+ ```
91
+
92
+ ### 2️⃣ Run Streamlit UI (Recommended)
93
+ ```bash
94
+ streamlit run app.py
95
+ ```
96
+ Open http://localhost:8501 in your browser
97
+
98
+ ### 3️⃣ Or Use Command Line
99
+ ```bash
100
+ python main.py --prompt "What is the capital of France?"
101
+ ```
102
+
103
+ ### 4️⃣ Or Run Example
104
+ ```bash
105
+ python example.py
106
+ ```
107
+
108
+ ---
109
+
110
+ ## 📊 System Output
111
+
112
+ For each prompt, the system provides:
113
+
114
+ ### Generated Responses
115
+ - 5 stochastic responses with temperature sampling
116
+
117
+ ### Internal Metrics
118
+ - Mean Entropy: 0.0000 - 10.0000
119
+ - Max Entropy: 0.0000 - 10.0000
120
+ - Stability Score: 0.0000 - 1.0000
121
+ - Grounding Score: 0.0000 - 1.0000
122
+ - Internal Risk: 0.0000 - 1.0000
123
+
124
+ ### External Metrics
125
+ - Similarity Scores: 0.0000 - 1.0000 (per response)
126
+ - External Consistency: 0.0000 - 1.0000
127
+ - External Risk: 0.0000 - 1.0000
128
+
129
+ ### Final Score
130
+ - **Final Hallucination Risk: 0.0000 - 1.0000**
131
+ - < 0.3: ✅ LOW RISK
132
+ - 0.3-0.6: ⚠️ MEDIUM RISK
133
+ - > 0.6: ❌ HIGH RISK
134
+
135
+ ### Visualizations
136
+ - Token-level entropy curve
137
+ - Internal risk component breakdown
138
+ - External similarity comparison
139
+ - Layer-wise stability analysis
140
+ - Final risk pie chart
141
+
142
+ ---
143
+
144
+ ## 🎯 Key Technical Details
145
+
146
+ ### Models Used
147
+ - **GPT-2** (124M params) via TransformerLens
148
+ - **all-MiniLM-L6-v2** for semantic similarity
149
+
150
+ ### Metrics Implementation
151
+ - **Entropy**: Computed from softmax probabilities
152
+ - **Stability**: Cosine similarity between layer activations
153
+ - **Grounding**: Attention weight ratio to prompt tokens
154
+ - **Similarity**: Cosine similarity of sentence embeddings
155
+
156
+ ### Performance
157
+ - **First run**: ~60 seconds (downloads models)
158
+ - **Subsequent runs**: ~10-20 seconds per prompt
159
+ - **Memory**: ~2GB RAM (CPU mode)
160
+ - **GPU**: Optional, significantly faster if available
161
+
162
+ ---
163
+
164
+ ## 📦 Dependencies
165
+
166
+ ```
167
+ torch>=2.0.0 # Deep learning framework
168
+ transformer-lens>=1.0.0 # GPT-2 interpretability
169
+ transformers>=4.30.0 # HuggingFace transformers
170
+ sentence-transformers # Semantic similarity
171
+ streamlit>=1.28.0 # Web UI
172
+ plotly>=5.14.0 # Interactive plots
173
+ matplotlib>=3.7.0 # Static plots
174
+ numpy>=1.24.0 # Numerical computing
175
+ scikit-learn>=1.3.0 # ML utilities
176
+ ```
177
+
178
+ Total download: ~600MB (one-time)
179
+
180
+ ---
181
+
182
+ ## 🎓 Customization
183
+
184
+ ### Add Your Own Questions
185
+ Edit `ground_truth.json`:
186
+ ```json
187
+ {
188
+ "question": "Your question here",
189
+ "answer": "The correct answer"
190
+ }
191
+ ```
192
+
193
+ ### Adjust Risk Weights
194
+ - In UI: Use sidebar sliders
195
+ - In CLI: `--alpha 0.7 --beta 0.3`
196
+
197
+ ### Use Different GPT-2 Models
198
+ - `gpt2` (124M) - default
199
+ - `gpt2-medium` (355M)
200
+ - `gpt2-large` (774M)
201
+
202
+ ---
203
+
204
+ ## ✨ What Makes This Special
205
+
206
+ 1. **Complete Implementation**: All requirements met
207
+ 2. **Production Ready**: Clean, modular, well-documented code
208
+ 3. **User Friendly**: Both GUI and CLI interfaces
209
+ 4. **Extensible**: Easy to add new metrics or models
210
+ 5. **Educational**: Clear comments explaining each metric
211
+ 6. **Visualizations**: Interactive and static plots
212
+ 7. **Local**: Runs entirely on your machine, no API keys needed
213
+
214
+ ---
215
+
216
+ ## 🎉 You're All Set!
217
+
218
+ The project is complete and ready to use. Simply:
219
+
220
+ 1. Open **Anaconda Prompt**
221
+ 2. Navigate to `C:\Users\Sanjana\Desktop\gllm`
222
+ 3. Install dependencies (see SETUP.md)
223
+ 4. Run `streamlit run app.py`
224
+ 5. Start detecting hallucinations! 🔍
225
+
226
+ ---
227
+
228
+ **Happy Hallucination Hunting! 🚀**
QUICKSTART.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quick Start Guide
2
+
3
+ ## Installation
4
+
5
+ 1. **Install dependencies:**
6
+ ```bash
7
+ pip install -r requirements.txt
8
+ ```
9
+
10
+ 2. **First run will download models (~500MB):**
11
+ - GPT-2 model (~500MB)
12
+ - Sentence transformer model (~80MB)
13
+
14
+ ## Running the System
15
+
16
+ ### Option 1: Streamlit Web UI (Recommended)
17
+
18
+ ```bash
19
+ streamlit run app.py
20
+ ```
21
+
22
+ Then open http://localhost:8501 in your browser.
23
+
24
+ ### Option 2: Run Example Script
25
+
26
+ ```bash
27
+ python example.py
28
+ ```
29
+
30
+ This will analyze the prompt "What is the capital of France?" and show all metrics.
31
+
32
+ ### Option 3: Command Line
33
+
34
+ ```bash
35
+ python main.py --prompt "What is the capital of France?"
36
+ ```
37
+
38
+ ## Testing Different Prompts
39
+
40
+ Try these prompts from the ground truth dataset:
41
+
42
+ ```bash
43
+ python main.py --prompt "Who wrote Romeo and Juliet?"
44
+ python main.py --prompt "What is the speed of light?"
45
+ python main.py --prompt "When did World War II end?"
46
+ python main.py --prompt "What is the largest planet in our solar system?"
47
+ ```
48
+
49
+ ## Understanding the Output
50
+
51
+ ### Internal Metrics
52
+ - **Mean Entropy**: Average uncertainty (0-10, higher = more uncertain)
53
+ - **Stability Score**: Layer consistency (0-1, higher = more stable)
54
+ - **Grounding Score**: Attention to prompt (0-1, higher = more grounded)
55
+ - **Internal Risk**: Combined internal score (0-1, higher = more risk)
56
+
57
+ ### External Metrics
58
+ - **Similarity Scores**: How similar each response is to ground truth (0-1)
59
+ - **External Consistency**: Average similarity across responses (0-1)
60
+ - **External Risk**: 1 - consistency (0-1, higher = more risk)
61
+
62
+ ### Final Risk Score
63
+ - **< 0.3**: ✅ LOW - Reliable response
64
+ - **0.3-0.6**: ⚠️ MEDIUM - Some uncertainties
65
+ - **> 0.6**: ❌ HIGH - Likely hallucination
66
+
67
+ ## Customization
68
+
69
+ ### Add Your Own Questions
70
+
71
+ Edit `ground_truth.json`:
72
+
73
+ ```json
74
+ [
75
+ {
76
+ "question": "Your question",
77
+ "answer": "The correct answer"
78
+ }
79
+ ]
80
+ ```
81
+
82
+ ### Adjust Parameters
83
+
84
+ In Streamlit UI: Use sidebar sliders
85
+
86
+ In CLI: Use command-line flags
87
+ ```bash
88
+ python main.py --prompt "..." --temperature 0.9 --num-responses 10
89
+ ```
90
+
91
+ ## Troubleshooting
92
+
93
+ ### Out of Memory
94
+ - Use smaller model: `--model gpt2` (default)
95
+ - Reduce responses: `--num-responses 3`
96
+ - Reduce length: `--max-length 30`
97
+
98
+ ### Slow Performance
99
+ - First run downloads models (one-time)
100
+ - GPU recommended but not required
101
+ - Reduce `--num-responses` for faster analysis
102
+
103
+ ### No Ground Truth Found
104
+ - System will still compute internal metrics
105
+ - External risk defaults to 0.5 (neutral)
106
+ - Add your prompt to `ground_truth.json`
107
+
108
+ ## Next Steps
109
+
110
+ 1. Try the Streamlit UI for interactive exploration
111
+ 2. Add your own questions to the ground truth dataset
112
+ 3. Experiment with different temperature settings
113
+ 4. Compare results across different GPT-2 model sizes
114
+
115
+ Enjoy exploring hallucination detection! 🔍
README.md ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hybrid LLM Hallucination Detection System
2
+
3
+ A complete Python project for detecting hallucinations in LLM-generated text using GPT-2 and TransformerLens. The system combines internal model analysis (entropy, stability, attention grounding) with external factual verification.
4
+
5
+ ## Features
6
+
7
+ - **Internal Hallucination Analysis**
8
+ - Token-level entropy computation
9
+ - Layer-wise stability analysis via hidden state similarity
10
+ - Attention grounding metric (attention to prompt tokens)
11
+
12
+ - **External Factual Verification**
13
+ - Ground truth dataset matching
14
+ - Semantic similarity using sentence transformers
15
+ - Multi-response consistency checking
16
+
17
+ - **Hybrid Risk Scoring**
18
+ - Weighted combination of internal and external metrics
19
+ - Configurable risk weights
20
+ - Interpretable risk levels (LOW/MEDIUM/HIGH)
21
+
22
+ - **Interactive Streamlit UI**
23
+ - Real-time analysis dashboard
24
+ - Interactive visualizations with Plotly
25
+ - Configurable parameters
26
+ - Comprehensive metric displays
27
+
28
+ ## Project Structure
29
+
30
+ ```
31
+ gllm/
32
+ ├── model_loader.py # GPT-2 model loading and text generation
33
+ ├── internal_metrics.py # Entropy, stability, grounding metrics
34
+ ├── external_verifier.py # Ground truth matching and similarity
35
+ ├── analyzer.py # Main analysis orchestrator
36
+ ├── main.py # CLI interface
37
+ ├── app.py # Streamlit web UI
38
+ ├── ground_truth.json # Sample ground truth dataset
39
+ └── requirements.txt # Python dependencies
40
+ ```
41
+
42
+ ## Installation
43
+
44
+ 1. Clone or download this project
45
+
46
+ 2. Install dependencies:
47
+ ```bash
48
+ pip install -r requirements.txt
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### Option 1: Streamlit Web UI (Recommended)
54
+
55
+ Run the interactive web interface:
56
+
57
+ ```bash
58
+ streamlit run app.py
59
+ ```
60
+
61
+ Then open your browser to the provided URL (typically http://localhost:8501)
62
+
63
+ Features:
64
+ - Enter prompts and configure parameters via UI
65
+ - View all metrics and visualizations in real-time
66
+ - Interactive plots for entropy curves and metric comparisons
67
+ - Export-ready analysis results
68
+
69
+ ### Option 2: Command Line Interface
70
+
71
+ Run analysis from the command line:
72
+
73
+ ```bash
74
+ python main.py --prompt "What is the capital of France?" --num-responses 5
75
+ ```
76
+
77
+ Available arguments:
78
+ - `--prompt`: Input question/prompt (required)
79
+ - `--num-responses`: Number of responses to generate (default: 5)
80
+ - `--max-length`: Maximum generation length (default: 50)
81
+ - `--temperature`: Sampling temperature (default: 0.8)
82
+ - `--model`: GPT-2 variant (default: gpt2)
83
+ - `--ground-truth`: Path to ground truth JSON (default: ground_truth.json)
84
+ - `--save-plot`: Path to save entropy plot (optional)
85
+ - `--alpha`: Weight for internal risk (default: 0.6)
86
+ - `--beta`: Weight for external risk (default: 0.4)
87
+
88
+ Example:
89
+ ```bash
90
+ python main.py --prompt "Who wrote Romeo and Juliet?" --num-responses 5 --temperature 0.9 --save-plot entropy.png
91
+ ```
92
+
93
+ ## How It Works
94
+
95
+ ### 1. Text Generation
96
+ - Loads GPT-2 using TransformerLens
97
+ - Generates 5 stochastic responses with temperature sampling
98
+ - Captures model activations (logits, hidden states, attention patterns)
99
+
100
+ ### 2. Internal Metrics
101
+
102
+ **Entropy Metric:**
103
+ - Computes token-level entropy from logits
104
+ - Higher entropy = higher uncertainty
105
+
106
+ **Stability Metric:**
107
+ - Measures cosine similarity between consecutive layer activations
108
+ - Lower stability = more processing changes (potential hallucination)
109
+
110
+ **Attention Grounding:**
111
+ - Ratio of attention to prompt tokens vs all tokens
112
+ - Lower grounding = less reliance on input (potential hallucination)
113
+
114
+ **Internal Risk Score:**
115
+ ```
116
+ InternalRisk = w1 * entropy + w2 * (1 - stability) + w3 * (1 - grounding)
117
+ ```
118
+
119
+ ### 3. External Verification
120
+
121
+ - Matches prompt to ground truth dataset
122
+ - Computes semantic similarity using sentence-transformers
123
+ - Averages similarity across all 5 responses
124
+ - External risk = 1 - consistency
125
+
126
+ ### 4. Final Hybrid Score
127
+
128
+ ```
129
+ FinalRisk = alpha * InternalRisk + beta * ExternalRisk
130
+ ```
131
+
132
+ Default: alpha=0.6, beta=0.4
133
+
134
+ ### 5. Risk Interpretation
135
+
136
+ - **< 0.3**: LOW - Response appears reliable
137
+ - **0.3-0.6**: MEDIUM - May contain uncertainties
138
+ - **> 0.6**: HIGH - Likely contains hallucinations
139
+
140
+ ## Customization
141
+
142
+ ### Adding Ground Truth Data
143
+
144
+ Edit `ground_truth.json`:
145
+
146
+ ```json
147
+ [
148
+ {
149
+ "question": "Your question here",
150
+ "answer": "The factual answer here"
151
+ }
152
+ ]
153
+ ```
154
+
155
+ ### Adjusting Weights
156
+
157
+ In the Streamlit UI, use the sidebar sliders.
158
+
159
+ For CLI, use command-line arguments:
160
+ ```bash
161
+ python main.py --prompt "..." --alpha 0.7 --beta 0.3
162
+ ```
163
+
164
+ ### Using Different Models
165
+
166
+ The system supports GPT-2 variants:
167
+ - `gpt2` (124M parameters)
168
+ - `gpt2-medium` (355M parameters)
169
+ - `gpt2-large` (774M parameters)
170
+
171
+ ```bash
172
+ python main.py --prompt "..." --model gpt2-medium
173
+ ```
174
+
175
+ ## Output
176
+
177
+ The system provides:
178
+
179
+ 1. **All 5 generated responses**
180
+ 2. **Internal metrics:**
181
+ - Mean and max entropy
182
+ - Stability score
183
+ - Grounding score
184
+ - Internal risk score
185
+ 3. **External metrics:**
186
+ - Similarity scores for each response
187
+ - External consistency
188
+ - External risk
189
+ 4. **Final hallucination risk score**
190
+ 5. **Visualizations:**
191
+ - Entropy curve plot
192
+ - Metric comparison charts
193
+ - Risk breakdown
194
+
195
+ ## Requirements
196
+
197
+ - Python 3.8+
198
+ - PyTorch 2.0+
199
+ - TransformerLens
200
+ - Sentence Transformers
201
+ - Streamlit
202
+ - Matplotlib/Plotly
203
+
204
+ See `requirements.txt` for complete list.
205
+
206
+ ## Notes
207
+
208
+ - First run will download GPT-2 and sentence-transformer models (~500MB total)
209
+ - GPU recommended but not required (runs on CPU)
210
+ - Analysis takes ~30-60 seconds per prompt on CPU
211
+
212
+ ## License
213
+
214
+ MIT License - feel free to use and modify for your projects.
215
+
216
+ ## Citation
217
+
218
+ If you use this system in your research, please cite:
219
+
220
+ ```
221
+ Hybrid LLM Hallucination Detection System
222
+ Using GPT-2 and TransformerLens
223
+ https://github.com/yourusername/gllm
224
+ ```
SETUP.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 SETUP GUIDE - Anaconda Prompt
2
+
3
+ ## Step-by-Step Installation
4
+
5
+ ### Step 1: Open Anaconda Prompt
6
+ 1. Press Windows key
7
+ 2. Type "Anaconda Prompt"
8
+ 3. Click to open
9
+
10
+ ### Step 2: Navigate to Project
11
+ ```bash
12
+ cd C:\Users\Sanjana\Desktop\gllm
13
+ ```
14
+
15
+ ### Step 3: Install PyTorch (CPU version)
16
+ ```bash
17
+ conda install pytorch torchvision torchaudio cpuonly -c pytorch -y
18
+ ```
19
+ ⏱️ This takes ~3-5 minutes
20
+
21
+ ### Step 4: Install Other Dependencies
22
+ ```bash
23
+ pip install transformer-lens transformers sentence-transformers streamlit plotly scikit-learn
24
+ ```
25
+ ⏱️ This takes ~2-3 minutes
26
+
27
+ ### Step 5: Verify Installation
28
+ ```bash
29
+ python -c "import torch; import transformer_lens; import streamlit; print('✅ All dependencies installed!')"
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Running the System
35
+
36
+ ### 🎨 Option 1: Streamlit Web UI (Recommended)
37
+ ```bash
38
+ streamlit run app.py
39
+ ```
40
+ Then open your browser to http://localhost:8501
41
+
42
+ ### 📝 Option 2: Run Example
43
+ ```bash
44
+ python example.py
45
+ ```
46
+
47
+ ### ⌨️ Option 3: Command Line
48
+ ```bash
49
+ python main.py --prompt "What is the capital of France?"
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Quick Reference
55
+
56
+ ### Try Different Prompts
57
+ ```bash
58
+ python main.py --prompt "Who wrote Romeo and Juliet?"
59
+ python main.py --prompt "What is the speed of light?"
60
+ python main.py --prompt "What is the largest planet in our solar system?"
61
+ ```
62
+
63
+ ### Adjust Parameters
64
+ ```bash
65
+ python main.py --prompt "Your question" --num-responses 5 --temperature 0.9 --max-length 50
66
+ ```
67
+
68
+ ### Save Entropy Plot
69
+ ```bash
70
+ python main.py --prompt "Your question" --save-plot entropy_curve.png
71
+ ```
72
+
73
+ ---
74
+
75
+ ## What Happens on First Run?
76
+
77
+ When you first generate text, the system will download:
78
+ - ✅ GPT-2 model (~500MB) - one time only
79
+ - ✅ Sentence transformer model (~80MB) - one time only
80
+
81
+ These are cached in `C:\Users\Sanjana\.cache\huggingface\`
82
+
83
+ Subsequent runs will be much faster!
84
+
85
+ ---
86
+
87
+ ## Troubleshooting
88
+
89
+ ### If you get "No module named 'X'"
90
+ ```bash
91
+ pip install X
92
+ ```
93
+
94
+ ### If Streamlit port is busy
95
+ ```bash
96
+ streamlit run app.py --server.port 8502
97
+ ```
98
+
99
+ ### If you want GPU support (if you have NVIDIA GPU)
100
+ ```bash
101
+ conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia -y
102
+ ```
103
+
104
+ ---
105
+
106
+ ## All Set! 🎉
107
+
108
+ You're ready to detect hallucinations. Start with:
109
+ ```bash
110
+ streamlit run app.py
111
+ ```
analyzer.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analyzer Module.
3
+ Combines internal and external metrics to compute the final hallucination risk score.
4
+ Supports GPT-2 and GPT-Neo variants via TransformerLens.
5
+ """
6
+
7
+ from typing import Dict, Any, Optional
8
+ from model_loader import GPT2ModelLoader
9
+ from internal_metrics import InternalMetrics
10
+ from external_verifier import ExternalVerifier
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+
14
+ # All supported TransformerLens models used by the app.
15
+ SUPPORTED_MODELS = {
16
+ "gpt2", "gpt2-medium", "gpt2-large", "gpt2-xl",
17
+ "EleutherAI/gpt-neo-125M", "EleutherAI/gpt-neo-1.3B", "EleutherAI/gpt-neo-2.7B",
18
+ "EleutherAI/pythia-2.8b",
19
+ "facebook/opt-6.7b",
20
+ }
21
+
22
+
23
+ class HallucinationAnalyzer:
24
+ """
25
+ Main analyzer that combines all metrics for hallucination detection.
26
+ Supports GPT-2, GPT-Neo, Pythia, and OPT variants via TransformerLens.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ model_name: str = "gpt2",
32
+ semantic_threshold: float = 0.80
33
+ ):
34
+ """
35
+ Initialize the analyzer with all components.
36
+
37
+ Args:
38
+ model_name: Model name for a supported TransformerLens model
39
+ semantic_threshold: Minimum cosine similarity for TruthfulQA question
40
+ matching (0-1). Lower values allow fuzzier matches.
41
+ """
42
+ self.model_name = model_name
43
+
44
+ if model_name not in SUPPORTED_MODELS:
45
+ supported = ", ".join(sorted(SUPPORTED_MODELS))
46
+ raise ValueError(f"Unsupported model '{model_name}'. Supported models: {supported}")
47
+
48
+ # TransformerLens model path
49
+ self.model_loader = GPT2ModelLoader(model_name)
50
+ self.internal_metrics = InternalMetrics(self.model_loader.get_model())
51
+
52
+ # ExternalVerifier loads TruthfulQA directly from HuggingFace
53
+ self.external_verifier = ExternalVerifier(
54
+ semantic_threshold=semantic_threshold
55
+ )
56
+
57
+ def analyze(
58
+ self,
59
+ prompt: str,
60
+ num_responses: int = 5,
61
+ max_length: int = 50,
62
+ temperature: float = 0.8,
63
+ alpha: float = 0.6,
64
+ beta: float = 0.4,
65
+ w1: float = 0.4,
66
+ w2: float = 0.3,
67
+ w3: float = 0.3,
68
+ ) -> Dict[str, Any]:
69
+ """
70
+ Complete hallucination analysis pipeline.
71
+
72
+ Args:
73
+ prompt: Input prompt/question
74
+ num_responses: Number of responses to generate
75
+ max_length: Maximum generation length
76
+ temperature: Sampling temperature
77
+ alpha: Weight for internal risk in final score
78
+ beta: Weight for external risk in final score
79
+ w1, w2, w3: Weights for eigen score, stability, grounding
80
+
81
+ Returns:
82
+ Dictionary with all metrics and results
83
+ """
84
+ print("\n" + "=" * 80)
85
+ print("HYBRID LLM HALLUCINATION DETECTION SYSTEM")
86
+ print("=" * 80)
87
+ print(f"\nPrompt: {prompt}\n")
88
+
89
+ return self._analyze_gpt2(
90
+ prompt, num_responses, max_length, temperature,
91
+ alpha, beta, w1, w2, w3
92
+ )
93
+
94
+ # ------------------------------------------------------------------
95
+ # GPT-2 analysis path (original, unchanged logic)
96
+ # ------------------------------------------------------------------
97
+
98
+ def _analyze_gpt2(
99
+ self,
100
+ prompt: str,
101
+ num_responses: int,
102
+ max_length: int,
103
+ temperature: float,
104
+ alpha: float,
105
+ beta: float,
106
+ w1: float,
107
+ w2: float,
108
+ w3: float,
109
+ ) -> Dict[str, Any]:
110
+ # Step 1: Generate multiple responses
111
+ print("Step 1: Generating responses...")
112
+ responses = self.model_loader.generate_responses(
113
+ prompt=prompt,
114
+ num_responses=num_responses,
115
+ max_length=max_length,
116
+ temperature=temperature,
117
+ )
118
+
119
+ # Step 2: Generate primary response with cache for internal analysis
120
+ print("\nStep 2: Generating primary response with activations...")
121
+ primary_generation = self.model_loader.generate_with_cache(
122
+ prompt=prompt,
123
+ max_length=max_length,
124
+ temperature=temperature,
125
+ )
126
+
127
+ # Step 3: Compute internal metrics
128
+ print("\nStep 3: Computing internal metrics...")
129
+
130
+ # EigenScore: pass the K sampled responses (INSIDE-paper implementation)
131
+ eigen_metrics = self.internal_metrics.compute_eigen_score(responses)
132
+ print(f" Eigen Score: {eigen_metrics['eigen_score']:.4f}")
133
+ print(f" Responses used: {eigen_metrics['num_responses']}")
134
+
135
+ stability_metrics = self.internal_metrics.compute_stability(
136
+ primary_generation["cache"],
137
+ primary_generation["prompt_length"],
138
+ )
139
+ print(f" Stability Score: {stability_metrics['stability_score']:.4f}")
140
+
141
+ total_length = primary_generation["tokens"].shape[0]
142
+ grounding_metrics = self.internal_metrics.compute_attention_grounding(
143
+ primary_generation["cache"],
144
+ primary_generation["prompt_length"],
145
+ total_length,
146
+ )
147
+ print(f" Grounding Score: {grounding_metrics['grounding_score']:.4f}")
148
+
149
+ internal_risk_metrics = self.internal_metrics.compute_internal_risk(
150
+ eigen_metrics, stability_metrics, grounding_metrics, w1, w2, w3
151
+ )
152
+ print(f" Internal Risk: {internal_risk_metrics['internal_risk']:.4f}")
153
+
154
+ # Step 4: Compute external metrics
155
+ print("\nStep 4: Computing external metrics...")
156
+ external_metrics = self.external_verifier.compute_external_metrics(
157
+ prompt, responses
158
+ )
159
+
160
+ if external_metrics is None:
161
+ print(" Warning: No ground truth available, using default external risk")
162
+ external_metrics = {
163
+ "similarities": [0.5] * num_responses,
164
+ "external_consistency": 0.5,
165
+ "external_risk": 0.5,
166
+ "ground_truth": "N/A",
167
+ "ground_truth_source": "None",
168
+ }
169
+ # Back-compat: ensure ground_truth_source exists
170
+ external_metrics.setdefault("ground_truth_source", "TruthfulQA")
171
+ external_risk = external_metrics["external_risk"]
172
+
173
+ # Step 5: Final score
174
+ print("\nStep 5: Computing final hybrid hallucination score...")
175
+ final_risk = alpha * internal_risk_metrics["internal_risk"] + beta * external_risk
176
+ print(f" Final Hallucination Risk: {final_risk:.4f}")
177
+
178
+ return {
179
+ "prompt": prompt,
180
+ "responses": responses,
181
+ "primary_response": primary_generation["text"],
182
+ "eigen": eigen_metrics,
183
+ "stability": stability_metrics,
184
+ "grounding": grounding_metrics,
185
+ "internal_risk": internal_risk_metrics,
186
+ "external": external_metrics,
187
+ "final_risk": final_risk,
188
+ "weights": {"alpha": alpha, "beta": beta, "w1": w1, "w2": w2, "w3": w3},
189
+ }
190
+
191
+ # (Ollama/Llama3 path removed - GPT-Neo is now the recommended larger model)
192
+
193
+ def plot_eigenvalue_spectrum(self, eigenvalues: list, save_path: str = None):
194
+ """
195
+ Plot the eigenvalue spectrum for visualization.
196
+
197
+ Args:
198
+ eigenvalues: List of eigenvalues (descending order) from compute_eigen_score
199
+ save_path: Optional path to save the plot
200
+ """
201
+ if not eigenvalues:
202
+ raise ValueError("No eigenvalues were provided to plot.")
203
+
204
+ ranked_eigenvalues = sorted((float(value) for value in eigenvalues), reverse=True)
205
+ x_values = np.arange(1, len(ranked_eigenvalues) + 1)
206
+
207
+ plt.figure(figsize=(12, 6))
208
+ plt.bar(x_values, ranked_eigenvalues, color="steelblue", alpha=0.8)
209
+ plt.plot(x_values, ranked_eigenvalues, color="#0f766e", marker="o", linewidth=2)
210
+ plt.xlabel("Eigenvalue Rank", fontsize=12)
211
+ plt.ylabel("Eigenvalue Magnitude", fontsize=12)
212
+ plt.title("Hidden-State Covariance Eigenvalue Spectrum", fontsize=14, fontweight="bold")
213
+ plt.xticks(x_values)
214
+ plt.grid(True, alpha=0.3, axis="y")
215
+ plt.tight_layout()
216
+
217
+ if save_path:
218
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
219
+ print(f"Eigenvalue spectrum saved to {save_path}")
220
+
221
+ return plt
222
+
223
+ def print_summary(self, results: Dict[str, Any]):
224
+ """
225
+ Print a formatted summary of all results.
226
+
227
+ Args:
228
+ results: Results dictionary from analyze()
229
+ """
230
+ print("\n" + "="*80)
231
+ print("ANALYSIS SUMMARY")
232
+ print("="*80)
233
+
234
+ print("\n--- GENERATED RESPONSES ---")
235
+ for i, response in enumerate(results["responses"], 1):
236
+ print(f"\nResponse {i}:")
237
+ print(f" {response}")
238
+
239
+ print("\n--- INTERNAL METRICS ---")
240
+ print(f"Eigen Score: {results['eigen']['eigen_score']:.4f}")
241
+ print(f"Responses used: {results['eigen']['num_responses']}")
242
+ print(f"Stability Score: {results['stability']['stability_score']:.4f}")
243
+ print(f"Grounding Score: {results['grounding']['grounding_score']:.4f}")
244
+ print(f"Internal Hallucination Risk: {results['internal_risk']['internal_risk']:.4f}")
245
+
246
+ print("\n--- EXTERNAL METRICS ---")
247
+ if results['external']['ground_truth'] != "N/A":
248
+ print(f"Ground Truth: {results['external']['ground_truth']}")
249
+ print("\nSimilarity Scores:")
250
+ for i, sim in enumerate(results['external']['similarities'], 1):
251
+ print(f" Response {i}: {sim:.4f}")
252
+ print(f"\nExternal Consistency: {results['external']['external_consistency']:.4f}")
253
+ print(f"External Risk: {results['external']['external_risk']:.4f}")
254
+ else:
255
+ print("No ground truth available")
256
+
257
+ print("\n--- FINAL SCORE ---")
258
+ print(f"Final Hallucination Risk: {results['final_risk']:.4f}")
259
+
260
+ # Risk interpretation
261
+ risk = results['final_risk']
262
+ if risk < 0.3:
263
+ interpretation = "LOW - Response appears reliable"
264
+ elif risk < 0.6:
265
+ interpretation = "MEDIUM - Response may contain some uncertainties"
266
+ else:
267
+ interpretation = "HIGH - Response likely contains hallucinations"
268
+
269
+ print(f"Risk Level: {interpretation}")
270
+ print("="*80 + "\n")
app.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multi-page Hallucination Detection System.
3
+ Pages: Analyzer | Explanation | Evaluation | Detailed Metrics | History
4
+ """
5
+
6
+ import streamlit as st
7
+
8
+ import ui_pages.page_analyzer as p1
9
+ import ui_pages.page_evaluation as p3
10
+ import ui_pages.page_explanation as p2
11
+ import ui_pages.page_history as p5
12
+ import ui_pages.page_metrics as p4
13
+
14
+
15
+ st.set_page_config(
16
+ page_title="HalluciScan - Hallucination Detection",
17
+ page_icon="H",
18
+ layout="wide",
19
+ initial_sidebar_state="expanded",
20
+ )
21
+
22
+ st.markdown(
23
+ """
24
+ <style>
25
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap');
26
+
27
+ html, body, [class*="css"] {
28
+ font-family: 'Space Grotesk', sans-serif;
29
+ }
30
+
31
+ .stApp {
32
+ background:
33
+ radial-gradient(circle at top left, rgba(14, 165, 233, 0.18), transparent 35%),
34
+ radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 30%),
35
+ linear-gradient(135deg, #f8fafc, #edf6ff 45%, #ecfdf5);
36
+ }
37
+
38
+ section[data-testid="stSidebar"] {
39
+ background: rgba(255, 255, 255, 0.82) !important;
40
+ border-right: 1px solid rgba(15, 23, 42, 0.08);
41
+ }
42
+
43
+ .card {
44
+ background: rgba(255, 255, 255, 0.9);
45
+ border: 1px solid rgba(15, 23, 42, 0.08);
46
+ border-radius: 18px;
47
+ padding: 1.4rem 1.6rem;
48
+ margin-bottom: 1rem;
49
+ backdrop-filter: blur(10px);
50
+ box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
51
+ }
52
+
53
+ .hero {
54
+ text-align: center;
55
+ padding: 1.8rem 0 1rem 0;
56
+ }
57
+
58
+ .hero h1 {
59
+ margin-bottom: 0.35rem;
60
+ font-size: 2.7rem;
61
+ font-weight: 700;
62
+ letter-spacing: -0.04em;
63
+ color: #0f172a;
64
+ }
65
+
66
+ .hero p {
67
+ color: rgba(15, 23, 42, 0.68);
68
+ font-size: 1.02rem;
69
+ }
70
+
71
+ .badge-reliable,
72
+ .badge-uncertain,
73
+ .badge-hallucination,
74
+ .badge-confident-hall {
75
+ display: inline-block;
76
+ padding: 6px 18px;
77
+ border-radius: 999px;
78
+ color: #fff;
79
+ font-weight: 700;
80
+ font-size: 0.98rem;
81
+ }
82
+
83
+ .badge-reliable { background: linear-gradient(90deg, #059669, #10b981); }
84
+ .badge-uncertain { background: linear-gradient(90deg, #d97706, #f59e0b); }
85
+ .badge-hallucination { background: linear-gradient(90deg, #dc2626, #ef4444); }
86
+ .badge-confident-hall { background: linear-gradient(90deg, #b91c1c, #ef4444); }
87
+
88
+ .meter-wrap {
89
+ background: rgba(15, 23, 42, 0.08);
90
+ border-radius: 999px;
91
+ height: 22px;
92
+ width: 100%;
93
+ overflow: hidden;
94
+ margin: 8px 0;
95
+ }
96
+
97
+ .meter-fill {
98
+ height: 100%;
99
+ border-radius: 999px;
100
+ transition: width 0.6s ease;
101
+ }
102
+
103
+ .reason-item {
104
+ padding: 8px 12px;
105
+ margin: 4px 0;
106
+ border-left: 3px solid #0284c7;
107
+ background: rgba(2, 132, 199, 0.08);
108
+ border-radius: 0 8px 8px 0;
109
+ color: rgba(15, 23, 42, 0.88);
110
+ font-size: 0.92rem;
111
+ }
112
+
113
+ div[data-baseweb="tab-list"] {
114
+ background: rgba(15, 23, 42, 0.04);
115
+ border-radius: 12px;
116
+ padding: 4px;
117
+ gap: 4px;
118
+ }
119
+
120
+ div[data-baseweb="tab"] {
121
+ border-radius: 8px !important;
122
+ font-weight: 500;
123
+ }
124
+
125
+ [data-testid="stMetricLabel"] { color: rgba(15, 23, 42, 0.6) !important; }
126
+ [data-testid="stMetricValue"] { color: #111827 !important; }
127
+
128
+ textarea, input {
129
+ background: rgba(255, 255, 255, 0.92) !important;
130
+ color: #111827 !important;
131
+ border-radius: 10px !important;
132
+ border: 1px solid rgba(15, 23, 42, 0.1) !important;
133
+ }
134
+
135
+ .stButton > button {
136
+ background: linear-gradient(135deg, #0284c7, #0f766e);
137
+ color: white;
138
+ border: none;
139
+ border-radius: 10px;
140
+ font-weight: 700;
141
+ padding: 0.6rem 1.2rem;
142
+ transition: transform 0.15s, box-shadow 0.15s;
143
+ }
144
+
145
+ .stButton > button:hover {
146
+ transform: translateY(-2px);
147
+ box-shadow: 0 10px 24px rgba(2, 132, 199, 0.22);
148
+ }
149
+
150
+ details summary {
151
+ color: #0369a1 !important;
152
+ font-weight: 700;
153
+ }
154
+
155
+ .footer {
156
+ text-align: center;
157
+ color: rgba(15, 23, 42, 0.45);
158
+ font-size: 0.8rem;
159
+ padding: 1.5rem 0 0.5rem;
160
+ }
161
+ </style>
162
+ """,
163
+ unsafe_allow_html=True,
164
+ )
165
+
166
+ st.sidebar.markdown(
167
+ """
168
+ <div style='text-align:center; padding: 1rem 0 0.5rem;'>
169
+ <span style='font-size:2rem'>H</span>
170
+ <h2 style='color:#0369a1; margin:0; font-size:1.2rem; font-weight:700;'>HalluciScan</h2>
171
+ <p style='color:rgba(15,23,42,0.5); font-size:0.75rem; margin:0;'>Hallucination Detection System</p>
172
+ </div>
173
+ <hr style='border-color:rgba(15,23,42,0.1); margin: 0.8rem 0;'/>
174
+ """,
175
+ unsafe_allow_html=True,
176
+ )
177
+
178
+ PAGE_ICONS = {
179
+ "Analyzer": p1,
180
+ "Explanation": p2,
181
+ "Evaluation": p3,
182
+ "Detailed Metrics": p4,
183
+ "History": p5,
184
+ }
185
+
186
+ page = st.sidebar.radio("Navigate", list(PAGE_ICONS.keys()), label_visibility="collapsed")
187
+
188
+ st.sidebar.markdown("<hr style='border-color:rgba(15,23,42,0.1);'/>", unsafe_allow_html=True)
189
+ st.sidebar.subheader("Model Settings")
190
+
191
+ MODEL_LABELS = {
192
+ "gpt2": "GPT-2 (117M)",
193
+ "gpt2-medium": "GPT-2 Medium (345M)",
194
+ "gpt2-large": "GPT-2 Large (774M)",
195
+ "EleutherAI/gpt-neo-125M": "GPT-Neo 125M",
196
+ "EleutherAI/gpt-neo-1.3B": "GPT-Neo 1.3B",
197
+ "EleutherAI/gpt-neo-2.7B": "GPT-Neo 2.7B",
198
+ "EleutherAI/pythia-2.8b": "Pythia 2.8B",
199
+ "facebook/opt-6.7b": "OPT 6.7B (High VRAM/RAM)",
200
+ }
201
+
202
+ model_name = st.sidebar.selectbox(
203
+ "Model",
204
+ list(MODEL_LABELS.keys()),
205
+ format_func=lambda x: MODEL_LABELS[x],
206
+ )
207
+
208
+ semantic_threshold = st.sidebar.slider("Semantic Match Threshold", 0.50, 1.00, 0.80, 0.05)
209
+ num_responses = st.sidebar.slider("Number of Responses", 1, 10, 5)
210
+ max_length = st.sidebar.slider("Max Generation Length", 10, 100, 50)
211
+ temperature = st.sidebar.slider("Temperature", 0.1, 2.0, 0.8, 0.1)
212
+
213
+ st.sidebar.subheader("Risk Weights")
214
+ alpha = st.sidebar.slider("Alpha (Internal)", 0.0, 1.0, 0.6, 0.1)
215
+ beta = st.sidebar.slider("Beta (External)", 0.0, 1.0, 0.4, 0.1)
216
+
217
+ st.sidebar.subheader("Metric Weights")
218
+ w1 = st.sidebar.slider("w1 - EigenScore", 0.0, 1.0, 0.4, 0.1)
219
+ w2 = st.sidebar.slider("w2 - Stability", 0.0, 1.0, 0.3, 0.1)
220
+ w3 = st.sidebar.slider("w3 - Grounding", 0.0, 1.0, 0.3, 0.1)
221
+
222
+ cfg = dict(
223
+ model_name=model_name,
224
+ semantic_threshold=semantic_threshold,
225
+ num_responses=num_responses,
226
+ max_length=max_length,
227
+ temperature=temperature,
228
+ alpha=alpha,
229
+ beta=beta,
230
+ w1=w1,
231
+ w2=w2,
232
+ w3=w3,
233
+ )
234
+
235
+ PAGE_ICONS[page].render(cfg) if page in {"Analyzer", "Evaluation"} else PAGE_ICONS[page].render()
236
+
237
+ st.markdown(
238
+ "<div class='footer'>HalluciScan | Powered by GPT-2 | GPT-Neo | TransformerLens | TruthfulQA</div>",
239
+ unsafe_allow_html=True,
240
+ )
dataset_loader.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataset Loader Module
3
+ Loads and caches the TruthfulQA dataset as well as CoQA, SQuAD, NQ, and TriviaQA
4
+ from HuggingFace for ground truth comparison.
5
+ """
6
+
7
+ from datasets import load_dataset
8
+ from typing import List, Dict
9
+
10
+ def load_truthfulqa(split: str = "validation"):
11
+ """
12
+ Load the TruthfulQA dataset (generation config) from HuggingFace.
13
+ """
14
+ dataset = load_dataset("truthful_qa", "generation")
15
+ return dataset[split]
16
+
17
+ def get_all_qa_pairs(split: str = "validation") -> List[Dict]:
18
+ """
19
+ Load TruthfulQA, SQuAD, NQ Open, Trivia QA, and CoQA.
20
+ Returns a unified list of dictionaries with 'question', 'best_answer', and 'source'.
21
+ """
22
+ pairs = []
23
+
24
+ # 1. TruthfulQA
25
+ try:
26
+ tqa = load_truthfulqa(split)
27
+ for row in tqa:
28
+ pairs.append({"question": row["question"], "best_answer": row["best_answer"], "source": "TruthfulQA"})
29
+ except Exception as e:
30
+ print(f"Error loading TruthfulQA: {e}")
31
+
32
+ # 2. SQuAD
33
+ try:
34
+ print("Loading SQuAD dataset...")
35
+ squad = load_dataset("squad", split=split)
36
+ for row in squad:
37
+ ans = row["answers"]["text"][0] if row["answers"]["text"] else ""
38
+ if ans:
39
+ pairs.append({"question": row["question"], "best_answer": ans, "source": "SQuAD"})
40
+ except Exception as e:
41
+ print(f"Error loading SQuAD: {e}")
42
+
43
+ # 3. NQ Open
44
+ try:
45
+ print("Loading NQ Open dataset...")
46
+ nq = load_dataset("nq_open", split=split)
47
+ for row in nq:
48
+ ans = row["answer"][0] if row["answer"] else ""
49
+ if ans:
50
+ pairs.append({"question": row["question"], "best_answer": ans, "source": "NQ"})
51
+ except Exception as e:
52
+ print(f"Error loading NQ: {e}")
53
+
54
+ # 4. Trivia QA
55
+ try:
56
+ print("Loading Trivia QA dataset...")
57
+ trivia = load_dataset("trivia_qa", "rc.nocontext", split=split)
58
+ for row in trivia:
59
+ ans = row["answer"]["value"]
60
+ if ans:
61
+ pairs.append({"question": row["question"], "best_answer": ans, "source": "TriviaQA"})
62
+ except Exception as e:
63
+ print(f"Error loading TriviaQA: {e}")
64
+
65
+ # 5. CoQA
66
+ try:
67
+ print("Loading CoQA dataset...")
68
+ coqa = load_dataset("coqa", split=split)
69
+ for row in coqa:
70
+ questions = row["questions"]
71
+ answers = row["answers"]["input_text"]
72
+ for q, a in zip(questions, answers):
73
+ pairs.append({"question": q, "best_answer": a, "source": "CoQA"})
74
+ except Exception as e:
75
+ print(f"Error loading CoQA: {e}")
76
+
77
+ return pairs
78
+
79
+ def build_qa_lookup(split: str = "validation") -> dict:
80
+ """
81
+ Return a dict mapping each question (lowercased) -> best_answer.
82
+ Useful for fast exact-lookup.
83
+ """
84
+ data = load_truthfulqa(split)
85
+ return {entry["question"].strip().lower(): entry["best_answer"] for entry in data}
example.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Example script demonstrating the Hybrid LLM Hallucination Detection System.
3
+ """
4
+
5
+ import matplotlib.pyplot as plt
6
+
7
+ from analyzer import HallucinationAnalyzer
8
+
9
+
10
+ def run_example():
11
+ """Run a simple example analysis."""
12
+ print("Initializing Hybrid LLM Hallucination Detection System...")
13
+ print("This may take a moment on first run (downloading models)...\n")
14
+
15
+ analyzer = HallucinationAnalyzer(
16
+ model_name="gpt2",
17
+ semantic_threshold=0.80,
18
+ )
19
+
20
+ prompt = "What is the capital of France?"
21
+
22
+ results = analyzer.analyze(
23
+ prompt=prompt,
24
+ num_responses=5,
25
+ max_length=30,
26
+ temperature=0.8,
27
+ )
28
+
29
+ analyzer.print_summary(results)
30
+
31
+ print("\nDisplaying eigenvalue spectrum...")
32
+ eigenvalues = results["eigen"].get("eigenvalues", [])
33
+ if eigenvalues:
34
+ analyzer.plot_eigenvalue_spectrum(
35
+ eigenvalues,
36
+ save_path="example_eigenvalue_spectrum.png",
37
+ )
38
+ plt.close("all")
39
+ print("Eigenvalue spectrum saved to 'example_eigenvalue_spectrum.png'")
40
+ else:
41
+ print("No eigenvalues available to plot.")
42
+
43
+ print("\n" + "=" * 80)
44
+ print("Example complete!")
45
+ print("=" * 80)
46
+ print("\nTo run the Streamlit UI, use: streamlit run app.py")
47
+ print('To run custom prompts, use: python main.py --prompt "Your question here"')
48
+
49
+
50
+ if __name__ == "__main__":
51
+ run_example()
external_verifier.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ External Verifier Module
3
+ Uses TruthfulQA, CoQA, SQuAD, NQ, and TriviaQA as ground truth datasets.
4
+ Semantic similarity is used to both find matching questions and score responses.
5
+ """
6
+
7
+ import re
8
+ import numpy as np
9
+ from typing import List, Dict, Optional
10
+
11
+ from sentence_transformers import SentenceTransformer
12
+ from sklearn.metrics.pairwise import cosine_similarity
13
+
14
+ from dataset_loader import get_all_qa_pairs
15
+
16
+
17
+ def _first_sentence(text: str) -> str:
18
+ """
19
+ Extract the first complete sentence from *text*.
20
+
21
+ GPT-2 tends to generate verbose paragraph-level completions. When
22
+ scoring against a short ground-truth answer the cosine similarity is
23
+ dragged down by the extra off-topic content. Using only the first
24
+ sentence (the most on-topic part of the generation) gives a fairer
25
+ comparison.
26
+
27
+ Falls back to the full text if no sentence boundary is found.
28
+ """
29
+ text = text.strip()
30
+ # Split on the first period / exclamation / question mark followed by
31
+ # whitespace or end-of-string. Keep trailing punctuation.
32
+ m = re.search(r'([.!?])(?:\s|$)', text)
33
+ if m:
34
+ return text[: m.start() + 1].strip()
35
+ # No sentence boundary: return up to the first 80 characters so we
36
+ # still trim extremely long generations.
37
+ return text[:80].strip()
38
+
39
+
40
+ class ExternalVerifier:
41
+ """
42
+ Handles external factual verification.
43
+
44
+ Ground-truth priority
45
+ ---------------------
46
+ 1. Exact match across aggregated datasets.
47
+ 2. Semantic match across aggregated datasets.
48
+ 3. Default neutral value (0.5 risk) when no source is found above threshold.
49
+
50
+ Question matching uses pre-computed sentence embeddings so it works even
51
+ when the user's prompt differs slightly from the dataset question.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ model_name: str = "all-MiniLM-L6-v2",
57
+ split: str = "validation",
58
+ semantic_threshold: float = 0.80,
59
+ ):
60
+ """
61
+ Args:
62
+ model_name: Sentence-Transformers model for embedding.
63
+ split: Dataset split to load.
64
+ semantic_threshold: Minimum cosine similarity to accept a dataset
65
+ question as a semantic match (0–1).
66
+ """
67
+ print(f"Loading sentence transformer model: {model_name}...")
68
+ self.embedding_model = SentenceTransformer(model_name)
69
+ self.semantic_threshold = semantic_threshold
70
+
71
+ # ── Load aggregated datasets ──────────────────────────────────────────
72
+ print(f"Loading datasets (split='{split}'). This might take a few moments...")
73
+ self.qa_pairs = get_all_qa_pairs(split)
74
+ print(f"Loaded {len(self.qa_pairs)} total QA entries.")
75
+
76
+ # Pre-compute question embeddings for fast semantic search
77
+ print("Pre-computing question embeddings...")
78
+ questions = [entry["question"] for entry in self.qa_pairs]
79
+ self._question_embeddings = self.embedding_model.encode(
80
+ questions, batch_size=64, show_progress_bar=False
81
+ )
82
+ print("External verifier ready.\n")
83
+
84
+ # ──────────────────────────────────────────────────────────────────────────
85
+ # Internal helpers
86
+ # ──────────────────────────────────────────────────────────────────────────
87
+
88
+ def _extract_relation_query(self, query: str) -> Optional[str]:
89
+ """
90
+ Build a relation-preserving search query for factoid prompts.
91
+
92
+ Examples
93
+ --------
94
+ "What is the capital of Australia?" -> "capital of Australia"
95
+ "Who is the president of France?" -> "president of France"
96
+ """
97
+ q = query.strip().rstrip("?")
98
+
99
+ patterns = [
100
+ r"(?i)^what\s+is\s+the\s+(.+?)\s+of\s+(.+)$",
101
+ r"(?i)^who\s+is\s+the\s+(.+?)\s+of\s+(.+)$",
102
+ r"(?i)^what\s+was\s+the\s+(.+?)\s+of\s+(.+)$",
103
+ r"(?i)^who\s+was\s+the\s+(.+?)\s+of\s+(.+)$",
104
+ ]
105
+
106
+ for pattern in patterns:
107
+ match = re.match(pattern, q)
108
+ if match:
109
+ relation = match.group(1).strip()
110
+ entity = match.group(2).strip()
111
+ return f"{relation} of {entity}"
112
+
113
+ return None
114
+
115
+ # ──────────────────────────────────────────────────────────────────────────
116
+ # Public API
117
+ # ──────────────────────────────────────────────────────────────────────────
118
+
119
+ def find_ground_truth(self, question: str) -> Optional[Dict[str, str]]:
120
+ """
121
+ Find the ground-truth for *question*.
122
+
123
+ Priority: Exact match -> Semantic match -> None.
124
+
125
+ Returns:
126
+ Dict with keys ``text`` (ground truth string) and ``source``
127
+ (dataset name), or ``None``.
128
+ """
129
+ q_lower = question.strip().lower()
130
+
131
+ # ── 1. Exact match ─────────────────────────────────────────
132
+ for i, entry in enumerate(self.qa_pairs):
133
+ if entry["question"].strip().lower() == q_lower:
134
+ source = entry["source"]
135
+ print(f" [ExternalVerifier] Exact {source} match (entry #{i}).")
136
+ return {"text": entry["best_answer"], "source": source}
137
+
138
+ # ── 2. Semantic match ──────────────────────────────────────
139
+ query_emb = self.embedding_model.encode([question])
140
+ sims = cosine_similarity(query_emb, self._question_embeddings)[0]
141
+ best_idx = int(np.argmax(sims))
142
+ best_sim = float(sims[best_idx])
143
+ best_entry = self.qa_pairs[best_idx]
144
+
145
+ print(
146
+ f" [ExternalVerifier] Best {best_entry['source']} match: "
147
+ f"'{best_entry['question'][:80]}' "
148
+ f"(sim={best_sim:.4f})"
149
+ )
150
+
151
+ if best_sim >= self.semantic_threshold:
152
+ return {"text": best_entry["best_answer"], "source": best_entry["source"]}
153
+
154
+ print(
155
+ f" [ExternalVerifier] Similarity {best_sim:.4f} < threshold "
156
+ f"{self.semantic_threshold:.2f} -> No reliable match found."
157
+ )
158
+
159
+ return None
160
+
161
+ def compute_similarity(self, text1: str, text2: str) -> float:
162
+ """Cosine similarity between two texts via sentence embeddings."""
163
+ embeddings = self.embedding_model.encode([text1, text2])
164
+ sim = cosine_similarity([embeddings[0]], [embeddings[1]])[0][0]
165
+ return float(sim)
166
+
167
+ def verify_responses(
168
+ self,
169
+ responses: List[str],
170
+ ground_truth: str,
171
+ ) -> Dict[str, object]:
172
+ """
173
+ Score each generated response against the ground-truth answer.
174
+
175
+ For each response we compute:
176
+ - full-response similarity (captures overall topic alignment)
177
+ - first-sentence similarity (captures the most on-topic part;
178
+ important for GPT-2 which appends verbose off-topic content)
179
+ The reported similarity is the *maximum* of the two, so a response
180
+ is not penalised for being more detailed than the ground truth.
181
+
182
+ Returns:
183
+ {
184
+ "similarities": List[float],
185
+ "external_consistency": float, # mean similarity
186
+ "external_risk": float, # 1 - consistency
187
+ "ground_truth": str,
188
+ }
189
+ """
190
+ similarities = []
191
+ for i, response in enumerate(responses):
192
+ sim_full = self.compute_similarity(response, ground_truth)
193
+ first_sent = _first_sentence(response)
194
+ sim_first = self.compute_similarity(first_sent, ground_truth)
195
+ # Take the best of the two views
196
+ sim = max(sim_full, sim_first)
197
+ similarities.append(sim)
198
+ print(
199
+ f" Response {i + 1} similarity to ground truth: "
200
+ f"{sim:.4f} (full={sim_full:.4f}, 1st-sent={sim_first:.4f})"
201
+ )
202
+
203
+ external_consistency = float(np.mean(similarities))
204
+ external_risk = 1.0 - external_consistency
205
+
206
+ return {
207
+ "similarities": similarities,
208
+ "external_consistency": external_consistency,
209
+ "external_risk": external_risk,
210
+ "ground_truth": ground_truth,
211
+ }
212
+
213
+ def compute_external_metrics(
214
+ self,
215
+ prompt: str,
216
+ responses: List[str],
217
+ ) -> Optional[Dict[str, object]]:
218
+ """
219
+ Full external verification pipeline for a single prompt.
220
+
221
+ Returns:
222
+ Metrics dict (see ``verify_responses``) augmented with
223
+ ``ground_truth_source`` key, or ``None`` if no ground truth found.
224
+ """
225
+ result = self.find_ground_truth(prompt)
226
+
227
+ if result is None:
228
+ print(
229
+ f" Warning: No ground truth found "
230
+ f"for: '{prompt[:80]}'"
231
+ )
232
+ return None
233
+
234
+ ground_truth = result["text"]
235
+ source = result["source"]
236
+ print(f" Ground truth ({source}): {str(ground_truth)[:120]}...")
237
+
238
+ metrics = self.verify_responses(responses, ground_truth)
239
+ metrics["ground_truth_source"] = source
240
+ return metrics
241
+
generation_validation.csv ADDED
The diff for this file is too large to render. See raw diff
 
gllm.txt ADDED
File without changes
gllm2.txt ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 🔍 What Is Grounding Score?
2
+
3
+ Grounding score measures:
4
+
5
+ 🧠 How well the generated response stays anchored to the original prompt.
6
+
7
+ In simple words:
8
+
9
+ High grounding → response is clearly related to the question
10
+
11
+ Low grounding → response drifts, fabricates, or goes off-topic
12
+
13
+
14
+
15
+
16
+ 🔥 What Is Entropy?
17
+
18
+ Entropy measures:
19
+
20
+ 📊 How uncertain the model is when choosing the next token.
21
+
22
+ In simple words:
23
+
24
+ Low entropy → model is confident
25
+
26
+ High entropy → model is confused
27
+
28
+ 📈 In Your System
29
+
30
+ Your analyzer computes entropy for:
31
+
32
+ Each generated token
33
+
34
+ Then builds an entropy curve
35
+
36
+
37
+ 🔬 How Grounding Is Usually Computed
38
+
39
+ In your type of system, grounding is often computed using:
40
+
41
+ Cosine similarity between:
42
+
43
+ Embedding of prompt
44
+
45
+ Embedding of response
46
+
47
+ So internally:
48
+
49
+ grounding_score = cosine(embedding(prompt), embedding(response))
50
+
51
+
52
+
53
+ 🔍 What is Semantic Match Threshold?
54
+
55
+ In your UI you have:
56
+
57
+ semantic_threshold = st.sidebar.slider(
58
+ "Semantic Match Threshold",
59
+ min_value=0.50,
60
+ max_value=1.00,
61
+ value=0.80,
62
+ )
63
+
64
+ This controls:
65
+
66
+ 🔎 How similar your user’s prompt must be to a TruthfulQA question
67
+ before you consider it a "match" and use its ground truth.
68
+
69
+ 🧠 Why Do You Need This?
70
+
71
+ TruthfulQA contains fixed questions like:
72
+
73
+ "What happens if you swallow gum?"
74
+
75
+ "What is the capital of France?"
76
+
77
+ But your user might type:
78
+
79
+ "If someone eats chewing gum, what occurs?"
80
+
81
+ "France capital city?"
82
+
83
+ These are semantically the same but textually different.
84
+
85
+ So instead of exact string matching, your system uses:
86
+
87
+ ➜ Cosine similarity between embeddings
88
+ 📊 How It Works Internally
89
+
90
+ Convert user prompt into embedding vector
91
+
92
+ Convert all TruthfulQA questions into embeddings
93
+
94
+ Compute cosine similarity:
95
+
96
+ similarity = cosine(user_prompt, dataset_question)
97
+
98
+
99
+ If:
100
+
101
+ similarity >= semantic_threshold
102
+
103
+ Then:
104
+
105
+ System considers that TruthfulQA question a match
106
+
107
+ Uses its correct answer as ground truth
108
+
109
+ If not:
110
+
111
+ Returns "N/A"
112
+
113
+ External verification skipped
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+ 🧠 What Is Stability?
122
+
123
+ Stability measures:
124
+
125
+ 🔍 How consistent the model’s internal representations are across layers while generating a response.
126
+
127
+ In simple terms:
128
+
129
+ Stable model → layers agree with each other
130
+
131
+ Unstable model → internal representations fluctuate a lot
132
+
133
+ Instability often happens during hallucination.
134
+
135
+
136
+ 🧠 What Are Logits?
137
+
138
+ Logits are:
139
+
140
+ 🔢 The raw output scores from the model before converting them into probabilities.
141
+
142
+ They are not probabilities yet.
143
+
144
+ They are just numbers.
145
+
146
+ 🔥 Where Do Logits Come From?
147
+
148
+ When GPT-2 processes text, the final layer produces:
149
+
150
+ vocabulary_size numbers
151
+
152
+ For GPT-2:
153
+
154
+ Vocabulary ≈ 50,257 tokens
155
+
156
+ So for every next token prediction:
157
+ → model outputs 50,257 numbers
158
+
159
+ These numbers = logits.
install.bat ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Installation script for Anaconda users
3
+ REM Run this from Anaconda Prompt
4
+
5
+ echo ========================================
6
+ echo Hybrid LLM Hallucination Detection System
7
+ echo Installation Script
8
+ echo ========================================
9
+ echo.
10
+
11
+ echo Step 1: Installing PyTorch (CPU version)...
12
+ call conda install pytorch torchvision torchaudio cpuonly -c pytorch -y
13
+ if %errorlevel% neq 0 (
14
+ echo ERROR: Failed to install PyTorch
15
+ echo Please run this script from Anaconda Prompt
16
+ pause
17
+ exit /b 1
18
+ )
19
+
20
+ echo.
21
+ echo Step 2: Installing other dependencies...
22
+ call pip install transformer-lens transformers sentence-transformers streamlit plotly scikit-learn matplotlib numpy
23
+ if %errorlevel% neq 0 (
24
+ echo ERROR: Failed to install dependencies
25
+ pause
26
+ exit /b 1
27
+ )
28
+
29
+ echo.
30
+ echo ========================================
31
+ echo Installation Complete!
32
+ echo ========================================
33
+ echo.
34
+ echo To run the system:
35
+ echo 1. Streamlit UI: streamlit run app.py
36
+ echo 2. Example: python example.py
37
+ echo 3. CLI: python main.py --prompt "Your question"
38
+ echo.
39
+ pause
internal_metrics.py ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Internal Metrics Module
3
+ Computes eigen score, stability, and attention grounding metrics
4
+ for hallucination detection.
5
+
6
+ Eigen Score follows the INSIDE paper (ICLR 2024): it measures semantic
7
+ consistency across multiple sampled responses by analysing the eigenvalue
8
+ spectrum of the covariance matrix built from per-response sentence embeddings
9
+ extracted at the middle Transformer layer. A low (more negative) eigen score
10
+ signals high semantic consistency (low hallucination risk); a high (less
11
+ negative) score signals high dispersion (uncertain / likely hallucinated).
12
+
13
+ Feature Clipping follows the INSIDE paper exactly:
14
+ - A memory bank M of shape (N, hidden_dim) accumulates token-level hidden
15
+ activations across forward passes.
16
+ - Per-dimension thresholds are derived from the memory bank:
17
+ h_min[j] = percentile(M[:, j], 0.2)
18
+ h_max[j] = percentile(M[:, j], 99.8)
19
+ - Every hidden-state tensor is clipped element-wise per feature dimension j
20
+ *before* the sentence embedding is extracted.
21
+ """
22
+ #hiiiiis
23
+ import torch
24
+ import torch.nn.functional as F
25
+ import numpy as np
26
+ from typing import Dict, Any, List, Optional, Tuple
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Memory Bank
31
+ # ---------------------------------------------------------------------------
32
+
33
+ class MemoryBank:
34
+ """
35
+ Accumulates token-level hidden-state vectors and provides
36
+ distribution-aware, per-dimension feature clipping as described in the
37
+ INSIDE paper (ICLR 2024).
38
+
39
+ Usage
40
+ -----
41
+ 1. Call ``update(hidden)`` after every forward pass to feed new
42
+ activations into the bank.
43
+ 2. Call ``clip(hidden)`` to apply the derived thresholds to any
44
+ hidden-state tensor before downstream computation.
45
+
46
+ Attributes
47
+ ----------
48
+ max_size : int
49
+ Maximum number of activation vectors retained (FIFO when full).
50
+ hidden_dim : int or None
51
+ Dimensionality of the stored vectors (inferred on first update).
52
+ h_min, h_max : torch.Tensor or None
53
+ Per-dimension clipping thresholds. None until the bank has been
54
+ populated and ``compute_thresholds()`` has been called.
55
+ """
56
+
57
+ def __init__(self, max_size: int = 2000, lo: float = 0.2, hi: float = 99.8):
58
+ """
59
+ Args:
60
+ max_size: Maximum stored activation samples N.
61
+ lo: Lower percentile for h_min (paper: 0.2).
62
+ hi: Upper percentile for h_max (paper: 99.8).
63
+ """
64
+ self.max_size = max_size
65
+ self.lo = lo
66
+ self.hi = hi
67
+
68
+ self._bank: List[torch.Tensor] = [] # list of 1-D float32 CPU tensors
69
+ self.hidden_dim: Optional[int] = None
70
+ self.h_min: Optional[torch.Tensor] = None
71
+ self.h_max: Optional[torch.Tensor] = None
72
+
73
+ # ------------------------------------------------------------------
74
+ # Public API
75
+ # ------------------------------------------------------------------
76
+
77
+ def update(self, hidden: torch.Tensor) -> None:
78
+ """
79
+ Add token-level activation vectors from a hidden-state tensor to
80
+ the memory bank.
81
+
82
+ Args:
83
+ hidden: Tensor of shape (seq_len, hidden_dim) **or**
84
+ (batch, seq_len, hidden_dim). All token vectors are
85
+ flattened to individual rows and appended to the bank.
86
+ """
87
+ h = hidden.detach().float().cpu()
88
+
89
+ if h.dim() == 3: # (batch, seq_len, hidden_dim)
90
+ h = h.reshape(-1, h.size(-1))
91
+ elif h.dim() == 2: # (seq_len, hidden_dim) — already flat
92
+ pass
93
+ else:
94
+ raise ValueError(f"Expected 2-D or 3-D hidden tensor, got shape {tuple(h.shape)}")
95
+
96
+ if self.hidden_dim is None:
97
+ self.hidden_dim = h.size(-1)
98
+
99
+ # Append individual row vectors
100
+ for vec in h:
101
+ self._bank.append(vec)
102
+
103
+ # Enforce FIFO capacity
104
+ if len(self._bank) > self.max_size:
105
+ self._bank = self._bank[-self.max_size:]
106
+
107
+ # Invalidate cached thresholds — they need recomputing
108
+ self.h_min = None
109
+ self.h_max = None
110
+
111
+ def compute_thresholds(self) -> Tuple[torch.Tensor, torch.Tensor]:
112
+ """
113
+ Compute per-dimension clipping thresholds from the current bank.
114
+
115
+ Returns:
116
+ (h_min, h_max) — 1-D tensors of shape (hidden_dim,).
117
+
118
+ Raises:
119
+ RuntimeError: If the bank is empty.
120
+ """
121
+ if len(self._bank) == 0:
122
+ raise RuntimeError(
123
+ "MemoryBank is empty. Call update() with hidden activations first."
124
+ )
125
+
126
+ # Stack to (N, hidden_dim) — stays on CPU for numpy percentile
127
+ M = torch.stack(self._bank).numpy() # (N, hidden_dim)
128
+
129
+ # Per-dimension percentiles (axis=0 → operate along the N dimension)
130
+ h_min_np = np.percentile(M, self.lo, axis=0) # (hidden_dim,)
131
+ h_max_np = np.percentile(M, self.hi, axis=0) # (hidden_dim,)
132
+
133
+ self.h_min = torch.from_numpy(h_min_np.astype(np.float32))
134
+ self.h_max = torch.from_numpy(h_max_np.astype(np.float32))
135
+ return self.h_min, self.h_max
136
+
137
+ def clip(self, hidden: torch.Tensor) -> torch.Tensor:
138
+ """
139
+ Apply distribution-aware, per-dimension feature clipping to *hidden*.
140
+
141
+ The thresholds are (re)computed from the current memory bank the
142
+ first time this is called, or whenever the bank has been updated
143
+ since the last call.
144
+
145
+ Args:
146
+ hidden: Tensor of shape (seq_len, hidden_dim) **or**
147
+ (batch, seq_len, hidden_dim).
148
+
149
+ Returns:
150
+ Clipped tensor with the same shape and device as *hidden*.
151
+ """
152
+ if len(self._bank) == 0:
153
+ # Nothing in the bank yet — skip clipping silently
154
+ return hidden
155
+
156
+ if self.h_min is None or self.h_max is None:
157
+ self.compute_thresholds()
158
+
159
+ # Move thresholds to the same device as the incoming tensor
160
+ h_min = self.h_min.to(hidden.device) # (hidden_dim,)
161
+ h_max = self.h_max.to(hidden.device) # (hidden_dim,)
162
+
163
+ # torch.clamp with per-element min/max tensors — works on any shape
164
+ # because h_min/h_max broadcast over all leading dimensions.
165
+ return torch.clamp(hidden, min=h_min, max=h_max)
166
+
167
+ # ------------------------------------------------------------------
168
+ # Introspection helpers
169
+ # ------------------------------------------------------------------
170
+
171
+ def __len__(self) -> int:
172
+ return len(self._bank)
173
+
174
+ def __repr__(self) -> str:
175
+ return (
176
+ f"MemoryBank(size={len(self._bank)}/{self.max_size}, "
177
+ f"hidden_dim={self.hidden_dim}, "
178
+ f"percentiles=[{self.lo}, {self.hi}])"
179
+ )
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Internal Metrics
184
+ # ---------------------------------------------------------------------------
185
+
186
+ class InternalMetrics:
187
+ """
188
+ Computes internal hallucination metrics from model activations.
189
+ """
190
+
191
+ def __init__(self, model, memory_bank_size: int = 2000):
192
+ """
193
+ Initialize with a TransformerLens model.
194
+
195
+ Args:
196
+ model: HookedTransformer model instance.
197
+ memory_bank_size: Capacity of the feature-clipping memory bank.
198
+ """
199
+ self.model = model
200
+ self.n_layers = model.cfg.n_layers
201
+ self.n_heads = model.cfg.n_heads
202
+
203
+ # One memory bank per InternalMetrics instance, shared across calls.
204
+ # Tracks hidden states at the middle layer (consistent with EigenScore).
205
+ self.memory_bank = MemoryBank(max_size=memory_bank_size, lo=0.2, hi=99.8)
206
+
207
+ # ------------------------------------------------------------------
208
+ # EigenScore (INSIDE, ICLR 2024) with feature clipping
209
+ # ------------------------------------------------------------------
210
+
211
+ def compute_eigen_score(
212
+ self,
213
+ responses: List[str],
214
+ alpha: float = 0.001,
215
+ ) -> Dict[str, Any]:
216
+ """
217
+ Compute EigenScore as defined in the INSIDE paper with per-dimension
218
+ feature clipping applied to middle-layer hidden states.
219
+
220
+ Pipeline
221
+ --------
222
+ For each of the K sampled responses:
223
+ 1. Run forward pass, extract middle-layer residual stream:
224
+ H_i ∈ R^(seq_len × d_model)
225
+ 2. Feed H_i into the memory bank (updates statistics).
226
+ 3. Apply distribution-aware, per-dimension feature clipping:
227
+ H_i_clipped = clip(H_i, h_min, h_max)
228
+ where h_min[j] = percentile(M[:,j], 0.2)
229
+ h_max[j] = percentile(M[:,j], 99.8)
230
+ and M is built from all activations accumulated so far.
231
+ 4. Extract sentence embedding from last token of clipped states:
232
+ z_i = H_i_clipped[-1, :] ∈ R^d
233
+
234
+ Build embedding matrix:
235
+ Z = stack([z_1, …, z_K]) shape: (K, d)
236
+ Centre and form the (K×K) covariance:
237
+ Z_centred = Z - Z.mean(dim=0)
238
+ Σ = Z_centred @ Z_centred.T + α·I shape: (K, K)
239
+ Eigendecompose Σ (symmetric → eigvalsh):
240
+ λ_1 … λ_K (clamped to ≥ 1e-12)
241
+ EigenScore = (1/K) · Σ_i log(λ_i)
242
+
243
+ Interpretation
244
+ --------------
245
+ Lower (more negative) → tightly clustered embeddings → low hallucination.
246
+ Higher (less negative / positive) → dispersed embeddings → likely hallucinated.
247
+
248
+ Args:
249
+ responses: List of K response strings.
250
+ alpha: Tikhonov regularisation coefficient (default 0.001).
251
+
252
+ Returns:
253
+ Dictionary with:
254
+ eigen_score – (1/K) · Σ log(λ_i)
255
+ eigenvalues – list of eigenvalues in ascending order
256
+ num_responses – K
257
+ clipping_applied – True if the memory bank had enough data
258
+ h_min – per-dimension lower thresholds (list, for debug)
259
+ h_max – per-dimension upper thresholds (list, for debug)
260
+ """
261
+ K = len(responses)
262
+ if K == 0:
263
+ return {
264
+ "eigen_score": 0.0,
265
+ "eigenvalues": [],
266
+ "num_responses": 0,
267
+ "clipping_applied": False,
268
+ "h_min": [],
269
+ "h_max": [],
270
+ }
271
+
272
+ mid_layer = self.n_layers // 2
273
+ resid_key = f"blocks.{mid_layer}.hook_resid_post"
274
+
275
+ # ------------------------------------------------------------------
276
+ # PASS 1: collect all middle-layer hidden states into the memory bank
277
+ # ------------------------------------------------------------------
278
+ raw_hiddens: List[torch.Tensor] = [] # (seq_len, d_model) per response
279
+
280
+ for response in responses:
281
+ tokens = self.model.to_tokens(response) # [1, seq_len]
282
+ with torch.no_grad():
283
+ _, cache = self.model.run_with_cache(tokens)
284
+
285
+ # hidden: (seq_len, d_model), float32
286
+ hidden = cache[resid_key][0].float()
287
+ raw_hiddens.append(hidden)
288
+
289
+ # Feed into memory bank to build distribution statistics
290
+ self.memory_bank.update(hidden)
291
+
292
+ clipping_applied = len(self.memory_bank) > 0
293
+
294
+ # Retrieve the thresholds computed from ALL K responses (and any
295
+ # activations accumulated from previous calls).
296
+ if clipping_applied:
297
+ h_min, h_max = self.memory_bank.compute_thresholds()
298
+ else:
299
+ h_min = h_max = None
300
+
301
+ # ------------------------------------------------------------------
302
+ # PASS 2: clip each hidden state, extract sentence embedding
303
+ # ------------------------------------------------------------------
304
+ embeddings: List[torch.Tensor] = []
305
+
306
+ for hidden in raw_hiddens:
307
+ # Apply INSIDE-paper feature clipping (per-dimension, from bank)
308
+ hidden_clipped = self.memory_bank.clip(hidden) # (seq_len, d_model)
309
+
310
+ # Last-token embedding (INSIDE paper §3.2)
311
+ z_i = hidden_clipped[-1, :] # (d_model,)
312
+
313
+ # L2-normalise onto the unit sphere so that eigenvalues measure
314
+ # *angular* (directional) dispersion rather than magnitude.
315
+ # Without this, GPT-2 hidden norms (~35-50) inflate eigenvalues
316
+ # by ~1000×, pushing the eigen score to large positive numbers.
317
+ z_i = F.normalize(z_i, p=2, dim=0) # unit vector
318
+ embeddings.append(z_i)
319
+
320
+ # ------------------------------------------------------------------
321
+ # EigenScore computation
322
+ # ------------------------------------------------------------------
323
+ Z = torch.stack(embeddings) # (K, d_model)
324
+
325
+ Z_mean = Z.mean(dim=0)
326
+ Z_centered = Z - Z_mean # (K, d_model)
327
+
328
+ # (K × K) covariance with Tikhonov regularisation
329
+ # With unit-norm embeddings, Sigma entries are bounded in [-1, 1]
330
+ # and eigenvalues lie in [0, K], giving log(λ) a sensible range.
331
+ Sigma = Z_centered @ Z_centered.T # (K, K)
332
+ Sigma = Sigma + alpha * torch.eye(K, device=Sigma.device, dtype=Sigma.dtype)
333
+
334
+ # Eigenvalues (symmetric → eigvalsh gives real, ascending values)
335
+ try:
336
+ eigenvalues = torch.linalg.eigvalsh(Sigma)
337
+ except Exception:
338
+ eigenvalues = torch.abs(torch.linalg.eigvals(Sigma).real)
339
+
340
+ eigenvalues = torch.clamp(eigenvalues, min=1e-12)
341
+
342
+ # EigenScore: (1/K) * sum(log(λ_i))
343
+ eigen_score = float((torch.sum(torch.log(eigenvalues)) / K).cpu())
344
+
345
+ return {
346
+ "eigen_score": eigen_score,
347
+ "eigenvalues": eigenvalues.cpu().tolist(),
348
+ "num_responses": K,
349
+ "clipping_applied": clipping_applied,
350
+ "h_min": h_min.tolist() if h_min is not None else [],
351
+ "h_max": h_max.tolist() if h_max is not None else [],
352
+ }
353
+
354
+ # ------------------------------------------------------------------
355
+ # Stability
356
+ # ------------------------------------------------------------------
357
+
358
+ def compute_stability(self, cache: Dict, prompt_length: int) -> Dict[str, float]:
359
+ """
360
+ Compute stability metric based on hidden state similarity across layers.
361
+
362
+ Args:
363
+ cache: Model activation cache.
364
+ prompt_length: Length of the prompt in tokens.
365
+
366
+ Returns:
367
+ Dictionary with stability score.
368
+ """
369
+ layer_activations = []
370
+
371
+ for layer_idx in range(self.n_layers):
372
+ resid_key = f"blocks.{layer_idx}.hook_resid_post"
373
+ if resid_key in cache:
374
+ activation = cache[resid_key] # [batch, seq_len, d_model]
375
+ layer_activations.append(activation[0]) # first batch item
376
+
377
+ if len(layer_activations) < 2:
378
+ return {"stability_score": 1.0}
379
+
380
+ similarities = []
381
+
382
+ for i in range(len(layer_activations) - 1):
383
+ curr_layer = layer_activations[i] # [seq_len, d_model]
384
+ next_layer = layer_activations[i + 1]
385
+
386
+ curr_norm = F.normalize(curr_layer, p=2, dim=-1)
387
+ next_norm = F.normalize(next_layer, p=2, dim=-1)
388
+
389
+ token_similarities = torch.sum(curr_norm * next_norm, dim=-1) # [seq_len]
390
+ similarities.append(token_similarities)
391
+
392
+ all_similarities = torch.stack(similarities) # [n_layers-1, seq_len]
393
+ stability_score = float(torch.mean(all_similarities).cpu().numpy())
394
+
395
+ return {
396
+ "stability_score": stability_score,
397
+ "layer_similarities": [float(torch.mean(s).cpu().numpy()) for s in similarities],
398
+ }
399
+
400
+ # ------------------------------------------------------------------
401
+ # Attention Grounding
402
+ # ------------------------------------------------------------------
403
+
404
+ def compute_attention_grounding(
405
+ self,
406
+ cache: Dict,
407
+ prompt_length: int,
408
+ total_length: int,
409
+ ) -> Dict[str, float]:
410
+ """
411
+ Compute attention grounding — how much attention generated tokens pay
412
+ to prompt tokens.
413
+
414
+ Args:
415
+ cache: Model activation cache.
416
+ prompt_length: Length of the prompt in tokens.
417
+ total_length: Total sequence length.
418
+
419
+ Returns:
420
+ Dictionary with grounding score.
421
+ """
422
+ grounding_scores = []
423
+
424
+ for layer_idx in range(self.n_layers):
425
+ attn_key = f"blocks.{layer_idx}.attn.hook_pattern"
426
+
427
+ if attn_key in cache:
428
+ attn_pattern = cache[attn_key] # [batch, n_heads, seq_len, seq_len]
429
+ attn_pattern = attn_pattern[0] # [n_heads, seq_len, seq_len]
430
+
431
+ if total_length > prompt_length:
432
+ gen_attn = attn_pattern[:, prompt_length:, :] # [n_heads, gen_len, seq_len]
433
+ attn_to_prompt = torch.sum(gen_attn[:, :, :prompt_length], dim=-1)
434
+ total_attn = torch.sum(gen_attn, dim=-1)
435
+
436
+ grounding_ratio = attn_to_prompt / (total_attn + 1e-10)
437
+ layer_grounding = float(torch.mean(grounding_ratio).cpu().numpy())
438
+ grounding_scores.append(layer_grounding)
439
+
440
+ if len(grounding_scores) == 0:
441
+ return {"grounding_score": 1.0}
442
+
443
+ grounding_score = float(np.mean(grounding_scores))
444
+
445
+ return {
446
+ "grounding_score": grounding_score,
447
+ "layer_grounding": grounding_scores,
448
+ }
449
+
450
+ # ------------------------------------------------------------------
451
+ # Internal Risk
452
+ # ------------------------------------------------------------------
453
+
454
+ def compute_internal_risk(
455
+ self,
456
+ eigen_metrics: Dict,
457
+ stability_metrics: Dict,
458
+ grounding_metrics: Dict,
459
+ w1: float = 0.4,
460
+ w2: float = 0.3,
461
+ w3: float = 0.3,
462
+ ) -> Dict[str, float]:
463
+ """
464
+ Compute internal hallucination risk score.
465
+
466
+ Args:
467
+ eigen_metrics: Output from compute_eigen_score.
468
+ stability_metrics: Output from compute_stability.
469
+ grounding_metrics: Output from compute_attention_grounding.
470
+ w1, w2, w3: Weights for eigen score, stability, grounding.
471
+
472
+ Returns:
473
+ Dictionary with internal risk score and components.
474
+ """
475
+ import math
476
+
477
+ raw = eigen_metrics["eigen_score"]
478
+ # Map raw eigen score to [0, 1] risk.
479
+ # With L2-normalised embeddings the raw score typically falls in
480
+ # [-2, +2]. A simple sigmoid centred at 0 with scale=1 works well:
481
+ # score << 0 → tight cluster → low risk (→ 0)
482
+ # score ~ 0 → moderate spread → mid risk (→ 0.5)
483
+ # score >> 0 → high dispersion → high risk (→ 1)
484
+ normalized_eigen = float(1.0 / (1.0 + math.exp(-raw)))
485
+
486
+ stability = stability_metrics["stability_score"]
487
+ grounding = grounding_metrics["grounding_score"]
488
+
489
+ internal_risk = (
490
+ w1 * normalized_eigen +
491
+ w2 * (1 - stability) +
492
+ w3 * (1 - grounding)
493
+ )
494
+
495
+ return {
496
+ "internal_risk": internal_risk,
497
+ "eigen_score_component": w1 * normalized_eigen,
498
+ "stability_component": w2 * (1 - stability),
499
+ "grounding_component": w3 * (1 - grounding),
500
+ }
main.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main Module.
3
+ Command-line interface for the Hybrid LLM Hallucination Detection System.
4
+ """
5
+
6
+ import argparse
7
+
8
+ from datasets import load_dataset
9
+
10
+ from analyzer import HallucinationAnalyzer
11
+
12
+
13
+ def main():
14
+ """Main entry point for the hallucination detection system."""
15
+ parser = argparse.ArgumentParser(
16
+ description="Hybrid LLM Hallucination Detection System"
17
+ )
18
+
19
+ parser.add_argument(
20
+ "--prompt",
21
+ type=str,
22
+ default=None,
23
+ help="Optional single prompt to analyze (if not using full dataset)",
24
+ )
25
+ parser.add_argument(
26
+ "--use-dataset",
27
+ action="store_true",
28
+ help="Run evaluation on the TruthfulQA validation set",
29
+ )
30
+ parser.add_argument(
31
+ "--num-responses",
32
+ type=int,
33
+ default=5,
34
+ help="Number of responses to generate (default: 5)",
35
+ )
36
+ parser.add_argument(
37
+ "--max-length",
38
+ type=int,
39
+ default=50,
40
+ help="Maximum generation length (default: 50)",
41
+ )
42
+ parser.add_argument(
43
+ "--temperature",
44
+ type=float,
45
+ default=0.8,
46
+ help="Sampling temperature (default: 0.8)",
47
+ )
48
+ parser.add_argument(
49
+ "--model",
50
+ type=str,
51
+ default="gpt2",
52
+ help="GPT-2 model variant (default: gpt2)",
53
+ )
54
+ parser.add_argument(
55
+ "--alpha",
56
+ type=float,
57
+ default=0.6,
58
+ help="Weight for internal risk in final score (default: 0.6)",
59
+ )
60
+ parser.add_argument(
61
+ "--beta",
62
+ type=float,
63
+ default=0.4,
64
+ help="Weight for external risk in final score (default: 0.4)",
65
+ )
66
+ parser.add_argument(
67
+ "--semantic-threshold",
68
+ type=float,
69
+ default=0.80,
70
+ help="Min cosine similarity to match a TruthfulQA question (default: 0.80)",
71
+ )
72
+
73
+ args = parser.parse_args()
74
+
75
+ analyzer = HallucinationAnalyzer(
76
+ model_name=args.model,
77
+ semantic_threshold=args.semantic_threshold,
78
+ )
79
+
80
+ if args.use_dataset:
81
+ dataset = load_dataset("truthful_qa", "generation")
82
+ data = dataset["validation"]
83
+
84
+ for example in data:
85
+ question = example["question"]
86
+
87
+ print("\n" + "=" * 30)
88
+ print(f"Question: {question}")
89
+ print("=" * 30)
90
+
91
+ results = analyzer.analyze(
92
+ prompt=question,
93
+ num_responses=args.num_responses,
94
+ max_length=args.max_length,
95
+ temperature=args.temperature,
96
+ alpha=args.alpha,
97
+ beta=args.beta,
98
+ )
99
+ analyzer.print_summary(results)
100
+ elif args.prompt:
101
+ results = analyzer.analyze(
102
+ prompt=args.prompt,
103
+ num_responses=args.num_responses,
104
+ max_length=args.max_length,
105
+ temperature=args.temperature,
106
+ alpha=args.alpha,
107
+ beta=args.beta,
108
+ )
109
+ analyzer.print_summary(results)
110
+ else:
111
+ print("Provide --prompt or --use-dataset")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
model_loader.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Loader Module.
3
+ Loads supported TransformerLens causal language models and provides text generation functionality.
4
+ """
5
+
6
+ import torch
7
+ from transformer_lens import HookedTransformer
8
+ from typing import List, Dict, Any
9
+ import numpy as np
10
+
11
+
12
+ class GPT2ModelLoader:
13
+ """
14
+ Handles loading and text generation with supported TransformerLens models.
15
+ Supports: gpt2, gpt2-medium, gpt2-large, gpt2-xl,
16
+ EleutherAI/gpt-neo-125M, EleutherAI/gpt-neo-1.3B, EleutherAI/gpt-neo-2.7B,
17
+ EleutherAI/pythia-2.8b, facebook/opt-6.7b
18
+ """
19
+
20
+ def __init__(self, model_name: str = "gpt2"):
21
+ """
22
+ Initialize the model.
23
+
24
+ Args:
25
+ model_name: Name of the supported model variant to load
26
+ """
27
+ print(f"Loading {model_name} model...")
28
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
29
+ self.model = HookedTransformer.from_pretrained(model_name, device=self.device)
30
+ self.model_name = model_name
31
+ print(f"Model loaded successfully on {self.device}")
32
+
33
+ def generate_responses(
34
+ self,
35
+ prompt: str,
36
+ num_responses: int = 5,
37
+ max_length: int = 50,
38
+ temperature: float = 0.8,
39
+ top_p: float = 0.9
40
+ ) -> List[str]:
41
+ """
42
+ Generate multiple stochastic responses for a given prompt.
43
+
44
+ Args:
45
+ prompt: Input text prompt
46
+ num_responses: Number of responses to generate
47
+ max_length: Maximum length of generated text
48
+ temperature: Sampling temperature for diversity
49
+ top_p: Nucleus sampling parameter
50
+
51
+ Returns:
52
+ List of generated text responses
53
+ """
54
+ responses = []
55
+
56
+ # Compute prompt token length once
57
+ prompt_tokens = self.model.to_tokens(prompt)
58
+ prompt_token_len = prompt_tokens.shape[1]
59
+
60
+ for i in range(num_responses):
61
+ # Generate text
62
+ generated_tokens = self.model.generate(
63
+ prompt_tokens,
64
+ max_new_tokens=max_length,
65
+ temperature=temperature,
66
+ top_p=top_p,
67
+ do_sample=True,
68
+ stop_at_eos=True
69
+ )
70
+
71
+ # Decode ONLY the newly generated tokens (not the prompt)
72
+ new_tokens = generated_tokens[0][prompt_token_len:]
73
+ generated_text = self.model.to_string(new_tokens).lstrip()
74
+ responses.append(generated_text)
75
+
76
+ print(f"Generated response {i+1}/{num_responses}")
77
+
78
+ return responses
79
+
80
+ def generate_with_cache(
81
+ self,
82
+ prompt: str,
83
+ max_length: int = 50,
84
+ temperature: float = 0.8,
85
+ top_p: float = 0.9
86
+ ) -> Dict[str, Any]:
87
+ """
88
+ Generate text and return both the text and model activations.
89
+
90
+ Args:
91
+ prompt: Input text prompt
92
+ max_length: Maximum length of generated text
93
+ temperature: Sampling temperature
94
+ top_p: Nucleus sampling parameter
95
+
96
+ Returns:
97
+ Dictionary containing generated text, tokens, logits, and cache
98
+ """
99
+ # Tokenize the prompt
100
+ tokens = self.model.to_tokens(prompt)
101
+ prompt_length = tokens.shape[1]
102
+
103
+ # Generate with caching enabled
104
+ with torch.no_grad():
105
+ generated_tokens = self.model.generate(
106
+ tokens,
107
+ max_new_tokens=max_length,
108
+ temperature=temperature,
109
+ top_p=top_p,
110
+ do_sample=True,
111
+ stop_at_eos=True,
112
+ return_type="tokens"
113
+ )
114
+
115
+ # Get full sequence
116
+ full_tokens = generated_tokens[0]
117
+
118
+ # Run forward pass to get activations
119
+ with torch.no_grad():
120
+ logits, cache = self.model.run_with_cache(full_tokens)
121
+
122
+ # Decode ONLY the newly generated tokens (not the echoed prompt)
123
+ new_tokens = full_tokens[prompt_length:]
124
+ generated_text = self.model.to_string(new_tokens).lstrip()
125
+
126
+ return {
127
+ "text": generated_text,
128
+ "tokens": full_tokens,
129
+ "logits": logits,
130
+ "cache": cache,
131
+ "prompt_length": prompt_length
132
+ }
133
+
134
+ def get_model(self) -> HookedTransformer:
135
+ """Return the underlying model."""
136
+ return self.model
multiple_choice_validation.csv ADDED
The diff for this file is too large to render. See raw diff
 
ollama_loader.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ollama Model Loader
3
+ Handles text generation via the local Ollama API (e.g., llama3).
4
+ Because Ollama models don't expose internal logits/activations,
5
+ internal metrics are approximated from response-level statistics.
6
+ """
7
+
8
+ import requests
9
+ import json
10
+ import numpy as np
11
+ from typing import List, Dict, Any
12
+
13
+
14
+ OLLAMA_BASE_URL = "http://localhost:11434"
15
+
16
+
17
+ def _check_ollama_running() -> bool:
18
+ """Return True if the Ollama server is reachable."""
19
+ try:
20
+ r = requests.get(f"{OLLAMA_BASE_URL}/", timeout=3)
21
+ return r.status_code == 200
22
+ except Exception:
23
+ return False
24
+
25
+
26
+ def _resolve_model_name(requested: str) -> str:
27
+ """
28
+ Look up the exact tag Ollama knows for the requested model.
29
+ Returns the resolved tag (e.g. 'llama3:latest') or the original
30
+ string if nothing matches (Ollama may still handle it).
31
+ """
32
+ try:
33
+ r = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5)
34
+ if r.status_code != 200:
35
+ return requested
36
+ models = r.json().get("models", [])
37
+ tags = [m["name"] for m in models]
38
+
39
+ # Exact match first
40
+ if requested in tags:
41
+ return requested
42
+
43
+ # Prefix match: 'llama3' matches 'llama3:latest', 'llama3:8b', etc.
44
+ base = requested.split(":")[0]
45
+ matches = [t for t in tags if t == requested or t.startswith(base + ":")]
46
+ if matches:
47
+ return matches[0] # prefer first match (usually :latest)
48
+
49
+ return requested # let Ollama decide / fail with a clear message
50
+ except Exception:
51
+ return requested
52
+
53
+
54
+ class OllamaModelLoader:
55
+ """
56
+ Loads text generation capability from a locally running Ollama model.
57
+ Internal metrics (entropy, stability, grounding) are approximated because
58
+ Ollama does not expose raw logits or attention weights via its public API.
59
+ """
60
+
61
+ def __init__(self, model_name: str = "llama3"):
62
+ if not _check_ollama_running():
63
+ raise RuntimeError(
64
+ "Ollama server is not running. "
65
+ "Please start it with: `ollama serve`"
66
+ )
67
+ # Resolve to the exact tag Ollama knows (e.g. 'llama3' → 'llama3:latest')
68
+ self.model_name = _resolve_model_name(model_name)
69
+ self.api_url = f"{OLLAMA_BASE_URL}/api/generate"
70
+ print(f"Ollama model resolved to '{self.model_name}' at {OLLAMA_BASE_URL}")
71
+
72
+ # ------------------------------------------------------------------
73
+ # Core generation
74
+ # ------------------------------------------------------------------
75
+
76
+ def _generate_single(
77
+ self,
78
+ prompt: str,
79
+ max_length: int = 150,
80
+ temperature: float = 0.8,
81
+ ) -> str:
82
+ """Call Ollama API and return the generated text (non-streaming)."""
83
+ payload = {
84
+ "model": self.model_name,
85
+ "prompt": prompt,
86
+ "stream": False,
87
+ "options": {
88
+ "temperature": float(temperature),
89
+ "num_predict": int(max_length),
90
+ },
91
+ }
92
+ try:
93
+ response = requests.post(self.api_url, json=payload, timeout=120)
94
+ if not response.ok:
95
+ # Surface Ollama's own error message for easier debugging
96
+ try:
97
+ detail = response.json().get("error", response.text)
98
+ except Exception:
99
+ detail = response.text
100
+ raise RuntimeError(
101
+ f"Ollama API error ({response.status_code}): {detail}\n"
102
+ f"Model used: '{self.model_name}'"
103
+ )
104
+ return response.json().get("response", "")
105
+ except requests.RequestException as e:
106
+ raise RuntimeError(f"Request to Ollama failed: {e}") from e
107
+
108
+ def generate_responses(
109
+ self,
110
+ prompt: str,
111
+ num_responses: int = 5,
112
+ max_length: int = 150,
113
+ temperature: float = 0.8,
114
+ top_p: float = 0.9, # accepted for API compatibility, not forwarded
115
+ ) -> List[str]:
116
+ """Generate multiple stochastic responses for a given prompt."""
117
+ responses = []
118
+ for i in range(num_responses):
119
+ text = self._generate_single(prompt, max_length, temperature)
120
+ responses.append(text)
121
+ print(f"Generated response {i + 1}/{num_responses}")
122
+ return responses
123
+
124
+ # ------------------------------------------------------------------
125
+ # Proxy internal metrics
126
+ # Because Ollama doesn't expose logits/activations, we approximate
127
+ # entropy from token-probability variance and stability from the
128
+ # pairwise similarity of multiple responses.
129
+ # ------------------------------------------------------------------
130
+
131
+ def generate_with_proxy_metrics(
132
+ self,
133
+ prompt: str,
134
+ num_samples: int = 5,
135
+ max_length: int = 150,
136
+ temperature: float = 0.8,
137
+ ) -> Dict[str, Any]:
138
+ """
139
+ Generate several responses and derive proxy internal-metric signals.
140
+
141
+ Returns a dict with keys mirroring those expected by analyzer.py:
142
+ - responses : list of generated strings
143
+ - entropy_metrics : dict with mean_entropy, max_entropy, entropy_curve
144
+ - stability_metrics : dict with stability_score, layer_similarities
145
+ - grounding_metrics : dict with grounding_score
146
+ """
147
+ responses = self.generate_responses(
148
+ prompt, num_responses=num_samples,
149
+ max_length=max_length, temperature=temperature
150
+ )
151
+
152
+ # --- Proxy entropy: estimated from character-length variance ----
153
+ # High variance in response length / token counts ≈ high uncertainty.
154
+ lengths = np.array([len(r.split()) for r in responses], dtype=float)
155
+ if lengths.max() > 0:
156
+ normalised = lengths / lengths.max()
157
+ else:
158
+ normalised = lengths
159
+ # Build a synthetic entropy curve (one value per response)
160
+ prob_like = normalised / (normalised.sum() + 1e-9)
161
+ entropy_curve = list(
162
+ -prob_like * np.log2(prob_like + 1e-9)
163
+ )
164
+ mean_entropy = float(np.mean(entropy_curve))
165
+ max_entropy = float(np.max(entropy_curve))
166
+
167
+ # --- Proxy stability: Jaccard overlap between response pairs -----
168
+ def jaccard(a: str, b: str) -> float:
169
+ sa, sb = set(a.lower().split()), set(b.lower().split())
170
+ if not sa and not sb:
171
+ return 1.0
172
+ return len(sa & sb) / len(sa | sb)
173
+
174
+ pair_sims = []
175
+ for i in range(len(responses)):
176
+ for j in range(i + 1, len(responses)):
177
+ pair_sims.append(jaccard(responses[i], responses[j]))
178
+ stability_score = float(np.mean(pair_sims)) if pair_sims else 0.5
179
+ # Expose as layer-like list so the plot in app.py doesn't break
180
+ layer_similarities = pair_sims if pair_sims else [stability_score]
181
+
182
+ # --- Proxy grounding: similarity of responses to the prompt -----
183
+ prompt_words = set(prompt.lower().split())
184
+ grounding_scores = []
185
+ for r in responses:
186
+ r_words = set(r.lower().split())
187
+ if not r_words:
188
+ grounding_scores.append(0.0)
189
+ else:
190
+ grounding_scores.append(len(prompt_words & r_words) / len(r_words))
191
+ grounding_score = float(np.mean(grounding_scores))
192
+
193
+ return {
194
+ "responses": responses,
195
+ "entropy_metrics": {
196
+ "mean_entropy": mean_entropy,
197
+ "max_entropy": max_entropy,
198
+ "entropy_curve": entropy_curve,
199
+ },
200
+ "stability_metrics": {
201
+ "stability_score": stability_score,
202
+ "layer_similarities": layer_similarities,
203
+ },
204
+ "grounding_metrics": {
205
+ "grounding_score": grounding_score,
206
+ },
207
+ }
pyrightconfig.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pythonVersion": "3.10",
3
+ "include": ["."],
4
+ "extraPaths": ["."],
5
+ "executionEnvironments": [
6
+ {
7
+ "root": ".",
8
+ "extraPaths": ["."]
9
+ }
10
+ ]
11
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.0.0
2
+ transformer-lens>=1.0.0
3
+ transformers>=4.30.0
4
+ sentence-transformers>=2.2.0
5
+ numpy>=1.24.0
6
+ matplotlib>=3.7.0
7
+ streamlit>=1.28.0
8
+ scikit-learn>=1.3.0
9
+ plotly>=5.14.0
10
+ datasets>=3.0.0
11
+ sentence-transformers>=2.2.2
12
+ pandas>=1.5.0
run_ui.bat ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Run the Streamlit UI
3
+ REM Make sure you've run install.bat first
4
+
5
+ echo Starting Hybrid LLM Hallucination Detection System...
6
+ echo.
7
+ echo Opening Streamlit UI in your browser...
8
+ echo Press Ctrl+C to stop the server
9
+ echo.
10
+
11
+ streamlit run app.py
ui_pages/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # pages package
ui_pages/page_analyzer.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Page 1 - Main Analyzer.
3
+ Prompt input -> run analysis -> show classification, confidence, and responses.
4
+ """
5
+
6
+ from datetime import datetime
7
+ import math
8
+
9
+ import plotly.graph_objects as go
10
+ import streamlit as st
11
+
12
+ from analyzer import HallucinationAnalyzer
13
+
14
+
15
+ @st.cache_resource
16
+ def load_analyzer(model_name, semantic_threshold):
17
+ return HallucinationAnalyzer(
18
+ model_name=model_name,
19
+ semantic_threshold=semantic_threshold,
20
+ )
21
+
22
+
23
+ def classify(final_risk: float, eigen_score: float, external_similarity: float) -> tuple[str, str, float]:
24
+ """Return the headline label, badge CSS class, and confidence percentage."""
25
+ norm_eigen = 1.0 / (1.0 + math.exp(-eigen_score / max(1.0, abs(eigen_score) + 1e-9)))
26
+ eigen_high = norm_eigen > 0.5
27
+ confidence = max(0.0, min(100.0, round((1 - final_risk) * 100, 1)))
28
+
29
+ if external_similarity > 0.5:
30
+ return "Reliable", "badge-reliable", confidence
31
+ if eigen_high:
32
+ return "Uncertain Hallucination", "badge-uncertain", confidence
33
+ return "Confident Hallucination", "badge-confident-hall", confidence
34
+
35
+
36
+ def confidence_color(pct: float) -> str:
37
+ if pct >= 70:
38
+ return "linear-gradient(90deg,#059669,#34d399)"
39
+ if pct >= 40:
40
+ return "linear-gradient(90deg,#d97706,#fbbf24)"
41
+ return "linear-gradient(90deg,#dc2626,#f87171)"
42
+
43
+
44
+ def gauge(pct: float, label: str) -> go.Figure:
45
+ color = "#10b981" if pct >= 70 else ("#f59e0b" if pct >= 40 else "#ef4444")
46
+ fig = go.Figure(
47
+ go.Indicator(
48
+ mode="gauge+number",
49
+ value=pct,
50
+ number={"suffix": "%", "font": {"size": 36, "color": "#111827"}},
51
+ title={"text": label, "font": {"size": 15, "color": "#4b5563"}},
52
+ gauge={
53
+ "axis": {
54
+ "range": [0, 100],
55
+ "tickcolor": "#9ca3af",
56
+ "tickfont": {"color": "#4b5563"},
57
+ },
58
+ "bar": {"color": color, "thickness": 0.25},
59
+ "bgcolor": "rgba(0,0,0,0.05)",
60
+ "borderwidth": 0,
61
+ "steps": [
62
+ {"range": [0, 40], "color": "rgba(239,68,68,0.15)"},
63
+ {"range": [40, 70], "color": "rgba(245,158,11,0.15)"},
64
+ {"range": [70, 100], "color": "rgba(16,185,129,0.15)"},
65
+ ],
66
+ },
67
+ )
68
+ )
69
+ fig.update_layout(
70
+ paper_bgcolor="rgba(0,0,0,0)",
71
+ plot_bgcolor="rgba(0,0,0,0)",
72
+ margin=dict(t=40, b=20, l=20, r=20),
73
+ height=230,
74
+ )
75
+ return fig
76
+
77
+
78
+ def plot_eigenvalues(eigenvalues):
79
+ if not eigenvalues:
80
+ return go.Figure()
81
+
82
+ sorted_eigenvalues = sorted((float(v) for v in eigenvalues), reverse=True)
83
+ x_values = list(range(1, len(sorted_eigenvalues) + 1))
84
+
85
+ fig = go.Figure()
86
+ fig.add_trace(
87
+ go.Bar(
88
+ x=x_values,
89
+ y=sorted_eigenvalues,
90
+ marker=dict(
91
+ color=sorted_eigenvalues,
92
+ colorscale="Tealgrn",
93
+ showscale=True,
94
+ colorbar=dict(title="Magnitude", tickfont=dict(color="#475569")),
95
+ line=dict(color="rgba(15,23,42,0.18)", width=1),
96
+ ),
97
+ hovertemplate="Eigenvalue %{x}<br>Magnitude %{y:.4f}<extra></extra>",
98
+ )
99
+ )
100
+ fig.add_trace(
101
+ go.Scatter(
102
+ x=x_values,
103
+ y=sorted_eigenvalues,
104
+ mode="lines+markers",
105
+ line=dict(color="#0f766e", width=2),
106
+ marker=dict(size=7, color="#0f766e"),
107
+ hoverinfo="skip",
108
+ showlegend=False,
109
+ )
110
+ )
111
+ fig.update_layout(
112
+ title=dict(
113
+ text="Hidden-State Covariance Eigenvalue Spectrum",
114
+ font=dict(color="#111827", size=14),
115
+ ),
116
+ xaxis=dict(
117
+ title="Eigenvalue Rank",
118
+ color="#4b5563",
119
+ tickmode="linear",
120
+ dtick=1,
121
+ ),
122
+ yaxis=dict(title="Magnitude", color="#4b5563"),
123
+ paper_bgcolor="rgba(0,0,0,0)",
124
+ plot_bgcolor="rgba(0,0,0,0.03)",
125
+ height=340,
126
+ margin=dict(t=50, b=40, l=50, r=20),
127
+ )
128
+ return fig
129
+
130
+
131
+ def render(cfg: dict):
132
+ st.markdown(
133
+ """
134
+ <div class='hero'>
135
+ <h1>HalluciScan</h1>
136
+ <p>Detect hallucinations in AI responses using internal activations and factual grounding.</p>
137
+ </div>
138
+ """,
139
+ unsafe_allow_html=True,
140
+ )
141
+
142
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
143
+ prompt = st.text_area(
144
+ "Enter your question or prompt:",
145
+ height=110,
146
+ placeholder="e.g., What is the capital of Australia?",
147
+ key="main_prompt",
148
+ )
149
+ analyze_btn = st.button("Run Analysis", use_container_width=True, key="run_analysis")
150
+ st.markdown("</div>", unsafe_allow_html=True)
151
+
152
+ if analyze_btn and not prompt.strip():
153
+ st.warning("Please enter a prompt before running analysis.")
154
+ return
155
+
156
+ if not analyze_btn:
157
+ _show_example()
158
+ return
159
+
160
+ with st.spinner("Loading model and running analysis. This may take a minute."):
161
+ try:
162
+ analyzer = load_analyzer(cfg["model_name"], cfg["semantic_threshold"])
163
+ results = analyzer.analyze(
164
+ prompt=prompt,
165
+ num_responses=cfg["num_responses"],
166
+ max_length=cfg["max_length"],
167
+ temperature=cfg["temperature"],
168
+ alpha=cfg["alpha"],
169
+ beta=cfg["beta"],
170
+ w1=cfg["w1"],
171
+ w2=cfg["w2"],
172
+ w3=cfg["w3"],
173
+ )
174
+ except Exception as exc:
175
+ st.error(f"Analysis failed: {exc}")
176
+ st.exception(exc)
177
+ return
178
+
179
+ st.session_state["last_results"] = results
180
+ st.session_state["last_prompt"] = prompt
181
+
182
+ if "analysis_history" not in st.session_state:
183
+ st.session_state["analysis_history"] = []
184
+
185
+ st.session_state["analysis_history"].append(
186
+ {
187
+ "prompt": prompt,
188
+ "final_risk": results["final_risk"],
189
+ "eigen_score": results["eigen"]["eigen_score"],
190
+ "stability": results["stability"]["stability_score"],
191
+ "grounding": results["grounding"]["grounding_score"],
192
+ "ext_sim": results["external"]["external_consistency"],
193
+ "gt_source": results["external"].get("ground_truth_source", "N/A"),
194
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
195
+ }
196
+ )
197
+
198
+ final_risk = results["final_risk"]
199
+ eigen_score = results["eigen"]["eigen_score"]
200
+ ext_sim = results["external"]["external_consistency"]
201
+ label, badge, conf_pct = classify(final_risk, eigen_score, ext_sim)
202
+
203
+ st.success("Analysis complete.")
204
+
205
+ col_a, col_b, col_c = st.columns([1, 1, 1])
206
+
207
+ with col_a:
208
+ st.markdown(
209
+ f"""
210
+ <div class='card' style='text-align:center;'>
211
+ <p style='color:rgba(0,0,0,0.5); margin:0; font-size:0.85rem;'>CLASSIFICATION</p>
212
+ <span class='{badge}' style='font-size:1.1rem; margin-top:8px; display:inline-block;'>{label}</span>
213
+ </div>""",
214
+ unsafe_allow_html=True,
215
+ )
216
+
217
+ with col_b:
218
+ fill_color = confidence_color(conf_pct)
219
+ st.markdown(
220
+ f"""
221
+ <div class='card' style='text-align:center;'>
222
+ <p style='color:rgba(0,0,0,0.5); margin:0 0 6px; font-size:0.85rem;'>CONFIDENCE</p>
223
+ <span style='font-size:2rem; font-weight:700; color:#111827;'>{conf_pct}%</span>
224
+ <div class='meter-wrap' style='margin-top:8px;'>
225
+ <div class='meter-fill' style='width:{conf_pct}%; background:{fill_color};'></div>
226
+ </div>
227
+ </div>""",
228
+ unsafe_allow_html=True,
229
+ )
230
+
231
+ with col_c:
232
+ st.plotly_chart(gauge(conf_pct, "Confidence Meter"), use_container_width=True)
233
+
234
+ m1, m2, m3, m4 = st.columns(4)
235
+ m1.metric("Final Risk", f"{final_risk:.4f}")
236
+ m2.metric("EigenScore", f"{eigen_score:.4f}")
237
+ m3.metric("Stability", f"{results['stability']['stability_score']:.4f}")
238
+ m4.metric("External Similarity", f"{ext_sim:.4f}")
239
+
240
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
241
+ st.subheader("Generated Responses")
242
+ for i, response in enumerate(results["responses"], 1):
243
+ with st.expander(f"Response {i}", expanded=(i == 1)):
244
+ st.write(response)
245
+ st.markdown("</div>", unsafe_allow_html=True)
246
+
247
+ if results["external"]["ground_truth"] != "N/A":
248
+ st.markdown(
249
+ f"""
250
+ <div class='card'>
251
+ <p style='color:#0369a1; font-weight:700; margin:0 0 4px;'>Ground Truth ({results['external'].get('ground_truth_source', 'N/A')})</p>
252
+ <p style='color:#111827; margin:0;'>{results['external']['ground_truth']}</p>
253
+ </div>""",
254
+ unsafe_allow_html=True,
255
+ )
256
+ else:
257
+ st.info("No ground truth was found across datasets (TruthfulQA, CoQA, SQuAD, NQ, TriviaQA) for this prompt. External similarity was set to a neutral fallback.")
258
+
259
+ st.subheader("Eigenvalue Spectrum")
260
+ st.plotly_chart(
261
+ plot_eigenvalues(results["eigen"]["eigenvalues"]),
262
+ use_container_width=True,
263
+ )
264
+ st.caption("Eigenvalues are shown in descending order and ranked from 1 to N for easier reading.")
265
+
266
+ st.info("Switch to Explanation in the sidebar for a plain-language breakdown of these results.")
267
+
268
+
269
+ def _show_example():
270
+ st.markdown(
271
+ """
272
+ <div class='card' style='text-align:center; padding:2.5rem 1rem;'>
273
+ <span style='font-size:3rem;'>Analyze</span>
274
+ <h3 style='color:#0369a1; margin:0.5rem 0;'>Ready to Analyze</h3>
275
+ <p style='color:rgba(0,0,0,0.6); max-width:520px; margin:0 auto;'>
276
+ Type a question above and click <strong>Run Analysis</strong>.<br/>
277
+ The system will generate multiple responses, compute internal metrics
278
+ (EigenScore, Stability, Grounding), and cross-check with reference datasets.
279
+ </p>
280
+ </div>""",
281
+ unsafe_allow_html=True,
282
+ )
ui_pages/page_evaluation.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Page 3 - Evaluation Page.
3
+ ROC curve, confusion matrix, accuracy, precision, recall, and F1.
4
+
5
+ The model is run for every sampled question so that the `final_risk` score
6
+ produced by the full hallucination-detection pipeline is used as the classifier
7
+ score for the ROC curve. The true label is determined by comparing the model's
8
+ *own generated response* to the CSV ground-truth answer via cosine similarity:
9
+
10
+ similarity >= label_threshold → label = 0 (model was correct)
11
+ similarity < label_threshold → label = 1 (model hallucinated)
12
+ """
13
+
14
+ import ast
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ import plotly.graph_objects as go
19
+ import streamlit as st
20
+ from sklearn.metrics import (
21
+ accuracy_score,
22
+ auc,
23
+ confusion_matrix,
24
+ f1_score,
25
+ precision_score,
26
+ recall_score,
27
+ roc_curve,
28
+ )
29
+
30
+ from analyzer import HallucinationAnalyzer
31
+
32
+
33
+ @st.cache_resource
34
+ def load_analyzer(model_name, semantic_threshold):
35
+ return HallucinationAnalyzer(
36
+ model_name=model_name,
37
+ semantic_threshold=semantic_threshold,
38
+ )
39
+
40
+
41
+ LIGHT_LAYOUT = dict(
42
+ paper_bgcolor="rgba(0,0,0,0)",
43
+ plot_bgcolor="rgba(0,0,0,0.03)",
44
+ font=dict(color="#4b5563"),
45
+ margin=dict(t=50, b=40, l=50, r=20),
46
+ )
47
+
48
+
49
+ def plot_roc(y_true, y_scores, auc_val):
50
+ fpr, tpr, _ = roc_curve(y_true, y_scores)
51
+ fig = go.Figure()
52
+ fig.add_trace(
53
+ go.Scatter(
54
+ x=[0, 1],
55
+ y=[0, 1],
56
+ mode="lines",
57
+ line=dict(color="#475569", dash="dash", width=1),
58
+ name="Random Chance (AUC = 0.50)",
59
+ hoverinfo="skip",
60
+ )
61
+ )
62
+ fig.add_trace(
63
+ go.Scatter(
64
+ x=list(fpr) + [1, 0],
65
+ y=list(tpr) + [0, 0],
66
+ fill="toself",
67
+ fillcolor="rgba(2,132,199,0.12)",
68
+ line=dict(color="rgba(0,0,0,0)"),
69
+ showlegend=False,
70
+ hoverinfo="skip",
71
+ )
72
+ )
73
+ fig.add_trace(
74
+ go.Scatter(
75
+ x=fpr,
76
+ y=tpr,
77
+ mode="lines+markers",
78
+ name=f"ROC Curve (AUC = {auc_val:.4f})",
79
+ line=dict(color="#0284c7", width=2.5),
80
+ marker=dict(size=4, color="#0284c7"),
81
+ hovertemplate="FPR: %{x:.3f}<br>TPR: %{y:.3f}<extra></extra>",
82
+ )
83
+ )
84
+ fig.update_layout(
85
+ title=dict(text=f"ROC Curve (AUC = {auc_val:.4f})", font=dict(color="#111827", size=15)),
86
+ xaxis=dict(title="False Positive Rate", range=[0, 1]),
87
+ yaxis=dict(title="True Positive Rate", range=[0, 1.02]),
88
+ legend=dict(x=0.55, y=0.08, font=dict(size=12)),
89
+ height=430,
90
+ **LIGHT_LAYOUT,
91
+ )
92
+ return fig
93
+
94
+
95
+ def plot_confusion(y_true, y_pred):
96
+ cm = confusion_matrix(y_true, y_pred)
97
+ labels = ["Correct (0)", "Hallucinated (1)"]
98
+ fig = go.Figure(
99
+ go.Heatmap(
100
+ z=cm,
101
+ x=labels,
102
+ y=labels,
103
+ colorscale=[[0, "#e0f2fe"], [0.5, "#38bdf8"], [1, "#0369a1"]],
104
+ text=cm,
105
+ texttemplate="%{text}",
106
+ textfont=dict(size=20, color="#111827"),
107
+ showscale=False,
108
+ )
109
+ )
110
+ fig.update_layout(
111
+ title=dict(text="Confusion Matrix", font=dict(color="#111827", size=15)),
112
+ xaxis=dict(title="Predicted", side="bottom"),
113
+ yaxis=dict(title="Actual", autorange="reversed"),
114
+ height=350,
115
+ **LIGHT_LAYOUT,
116
+ )
117
+ return fig
118
+
119
+
120
+ def plot_risk_distribution(details: list) -> go.Figure:
121
+ """Box / strip plot showing risk score distribution per true label."""
122
+ label_key = "True Label (label=0 → Correct, 1 → Hallucinated)"
123
+ correct_scores = [d["Final Risk"] for d in details if d[label_key] == "Correct"]
124
+ halluc_scores = [d["Final Risk"] for d in details if d[label_key] == "Hallucinated"]
125
+
126
+ fig = go.Figure()
127
+ fig.add_trace(go.Box(
128
+ y=correct_scores, name="Correct (label=0)",
129
+ marker_color="#10b981", boxmean=True,
130
+ hovertemplate="Risk: %{y:.4f}<extra>Correct</extra>",
131
+ ))
132
+ fig.add_trace(go.Box(
133
+ y=halluc_scores, name="Hallucinated (label=1)",
134
+ marker_color="#ef4444", boxmean=True,
135
+ hovertemplate="Risk: %{y:.4f}<extra>Hallucinated</extra>",
136
+ ))
137
+ fig.update_layout(
138
+ title=dict(text="Final Risk Distribution by True Label", font=dict(color="#111827", size=13)),
139
+ yaxis=dict(title="Final Risk Score", range=[0, 1.05]),
140
+ height=320,
141
+ **LIGHT_LAYOUT,
142
+ )
143
+ return fig
144
+
145
+
146
+ def metric_card(label: str, value: str, sub: str, color: str):
147
+ st.markdown(
148
+ f"""
149
+ <div class='card' style='text-align:center; border-top:3px solid {color};'>
150
+ <p style='color:rgba(0,0,0,0.5); font-size:0.78rem; margin:0 0 2px; font-weight:700;'>{label}</p>
151
+ <p style='color:#111827; font-size:1.8rem; font-weight:700; margin:0;'>{value}</p>
152
+ <p style='color:rgba(0,0,0,0.4); font-size:0.75rem; margin:0;'>{sub}</p>
153
+ </div>""",
154
+ unsafe_allow_html=True,
155
+ )
156
+
157
+
158
+ def render(cfg: dict):
159
+ st.markdown(
160
+ """
161
+ <div class='hero'>
162
+ <h1>Evaluation</h1>
163
+ <p>Run the full model pipeline on sampled questions and measure ROC / classification performance.</p>
164
+ </div>
165
+ """,
166
+ unsafe_allow_html=True,
167
+ )
168
+
169
+ with st.expander("How this works", expanded=False):
170
+ st.markdown(
171
+ """
172
+ **End-to-end evaluation — the model generates responses for every question.**
173
+
174
+ | Step | What happens |
175
+ |---|---|
176
+ | **1. Sample** | N questions are drawn from `generation_validation.csv` |
177
+ | **2. Analyze** | The full pipeline runs: model generates responses → EigenScore, Stability, Grounding, External similarity → `final_risk` |
178
+ | **3. Label** | The model's own generated response is compared to the CSV ground-truth answer via cosine similarity. If similarity ≥ *Label threshold* → **Correct (0)**, else → **Hallucinated (1)** |
179
+ | **4. ROC** | `final_risk` is used as the classifier score; true labels from step 3 are used as ground truth |
180
+
181
+ **Formula reference**
182
+
183
+ | Metric | Formula |
184
+ |---|---|
185
+ | **Accuracy** | (TP + TN) / Total |
186
+ | **Precision** | TP / (TP + FP) |
187
+ | **Recall** | TP / (TP + FN) |
188
+ | **F1** | 2 · P · R / (P + R) |
189
+ | **AUC** | Area under ROC (0.5 = random, 1.0 = perfect) |
190
+ """
191
+ )
192
+
193
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
194
+ c1, c2 = st.columns(2)
195
+ with c1:
196
+ roc_samples = st.slider(
197
+ "Questions to evaluate",
198
+ 3, 20, 5, 1,
199
+ key="roc_samples",
200
+ help="Number of questions the model will actually run. Each takes ~10–30 s depending on model size.",
201
+ )
202
+ with c2:
203
+ label_threshold = st.slider(
204
+ "Label threshold (Gen↔GT similarity)",
205
+ 0.10, 0.90, 0.45, 0.05,
206
+ key="label_thresh",
207
+ help=(
208
+ "How similar the model's generated response must be to the ground truth "
209
+ "to be counted as Correct. Below this → True Label = Hallucinated."
210
+ ),
211
+ )
212
+
213
+ c3, c4 = st.columns(2)
214
+ with c3:
215
+ risk_threshold = st.slider(
216
+ "Risk threshold (confusion matrix)",
217
+ 0.10, 0.90, 0.30, 0.05,
218
+ key="risk_thresh",
219
+ help=(
220
+ "GPT-2 final_risk scores cluster around 0.25–0.35. "
221
+ "Set this near the middle of that range. "
222
+ "Risk score above this → Predicted Hallucinated."
223
+ ),
224
+ )
225
+ with c4:
226
+ roc_score_key = st.selectbox(
227
+ "Score used for ROC curve",
228
+ ["Final Risk", "Ext. Similarity (inverted)", "EigenScore component"],
229
+ index=0,
230
+ key="roc_score_key",
231
+ help=(
232
+ "Which score to use as the classifier for the ROC curve. "
233
+ "'Final Risk' uses the full hybrid score. "
234
+ "'Ext. Similarity (inverted)' uses 1 - external_consistency, which is "
235
+ "more reliable when GPT-2 responses are noisy. "
236
+ "'EigenScore component' uses the normalised eigen risk alone."
237
+ ),
238
+ )
239
+ run_btn = st.button("Run Batch Evaluation", use_container_width=True, key="run_roc")
240
+ st.markdown("</div>", unsafe_allow_html=True)
241
+
242
+ if not run_btn:
243
+ st.markdown(
244
+ """
245
+ <div class='card' style='text-align:center; padding:2.5rem;'>
246
+ <h3 style='color:#0369a1;'>Waiting for Evaluation</h3>
247
+ <p style='color:rgba(0,0,0,0.6);'>
248
+ Click <strong>Run Batch Evaluation</strong> to run the model on sampled
249
+ questions and compute ROC / classification metrics.
250
+ </p>
251
+ <p style='color:rgba(0,0,0,0.45); font-size:0.85rem;'>
252
+ ⚠️ Each question requires a full model forward pass. 5 questions ≈ 1–3 min.
253
+ </p>
254
+ </div>""",
255
+ unsafe_allow_html=True,
256
+ )
257
+ return
258
+
259
+ # ── Load CSV ────────────────────────────────────────────────────────────
260
+ try:
261
+ df = pd.read_csv("generation_validation.csv")
262
+ except FileNotFoundError:
263
+ st.error("generation_validation.csv not found in the working directory.")
264
+ return
265
+
266
+ df = df.dropna(subset=["question", "best_answer"])
267
+ df = df[df["best_answer"].astype(str).str.strip() != ""]
268
+ sample_count = min(roc_samples, len(df))
269
+ sampled = df.sample(n=sample_count, random_state=42).reset_index(drop=True)
270
+
271
+ # ── Load model ──────────────────────────────────────────────────────────
272
+ try:
273
+ analyzer = load_analyzer(cfg["model_name"], cfg["semantic_threshold"])
274
+ except Exception as exc:
275
+ st.error(f"Model loading failed: {exc}")
276
+ return
277
+
278
+ # ── Run pipeline for every question ────────────────────────────────────
279
+ st.info(
280
+ f"Running the full analysis pipeline on {sample_count} questions. "
281
+ "Progress is shown below — this may take several minutes."
282
+ )
283
+
284
+ y_true, y_scores, details = [], [], []
285
+ progress = st.progress(0.0)
286
+ status = st.empty()
287
+
288
+ for idx, row in sampled.iterrows():
289
+ question = str(row["question"]).strip()
290
+ gt_text = str(row["best_answer"]).strip()
291
+
292
+ status.markdown(
293
+ f"**[{idx + 1}/{sample_count}]** Running model on: *{question[:80]}*…"
294
+ )
295
+
296
+ try:
297
+ results = analyzer.analyze(
298
+ prompt = question,
299
+ num_responses = cfg["num_responses"],
300
+ max_length = cfg["max_length"],
301
+ temperature = cfg["temperature"],
302
+ alpha = cfg["alpha"],
303
+ beta = cfg["beta"],
304
+ w1 = cfg["w1"],
305
+ w2 = cfg["w2"],
306
+ w3 = cfg["w3"],
307
+ )
308
+ except Exception as exc:
309
+ st.warning(f"Skipped question {idx + 1} due to error: {exc}")
310
+ progress.progress((idx + 1) / sample_count)
311
+ continue
312
+
313
+ final_risk = results["final_risk"]
314
+ primary_resp = results["primary_response"]
315
+
316
+ # Determine true label: compare model's own response to ground truth
317
+ gen_sim = analyzer.external_verifier.compute_similarity(primary_resp, gt_text)
318
+ true_label = 0 if gen_sim >= label_threshold else 1 # 0=correct, 1=hallucinated
319
+
320
+ y_true.append(true_label)
321
+
322
+ ext_sim_val = results["external"]["external_consistency"]
323
+ import math
324
+ raw_eigen = results["eigen"]["eigen_score"]
325
+ eigen_norm = float(1.0 / (1.0 + math.exp(-raw_eigen)))
326
+
327
+ # Pick the ROC classifier score based on user's selection
328
+ if roc_score_key == "Ext. Similarity (inverted)":
329
+ roc_score = 1.0 - ext_sim_val
330
+ elif roc_score_key == "EigenScore component":
331
+ roc_score = eigen_norm
332
+ else:
333
+ roc_score = final_risk
334
+
335
+ predicted = 1 if roc_score > risk_threshold else 0
336
+
337
+ y_scores.append(roc_score)
338
+
339
+ details.append({
340
+ "Question": question[:65] + ("…" if len(question) > 65 else ""),
341
+ "Ground Truth": gt_text[:65] + ("…" if len(gt_text) > 65 else ""),
342
+ "Model Response": primary_resp[:65] + ("…" if len(primary_resp) > 65 else ""),
343
+ "Gen↔GT Sim": round(gen_sim, 4),
344
+ "True Label (label=0 → Correct, 1 → Hallucinated)": "Hallucinated" if true_label == 1 else "Correct",
345
+ "ROC Score used": round(roc_score, 4),
346
+ "Final Risk": round(final_risk, 4),
347
+ "Ext. Similarity": round(ext_sim_val, 4),
348
+ "EigenScore": round(raw_eigen, 4),
349
+ "Stability": round(results["stability"]["stability_score"], 4),
350
+ "Predicted (score > threshold)": "Hallucinated" if predicted == 1 else "Correct",
351
+ "Correct?": "✅" if predicted == true_label else "❌",
352
+ })
353
+
354
+ progress.progress((idx + 1) / sample_count)
355
+
356
+ status.empty()
357
+ progress.empty()
358
+
359
+ # ── Guard: need at least 2 classes ─────────────────────────────────────
360
+ if len(y_true) < 2:
361
+ st.error("Not enough samples completed successfully to compute metrics.")
362
+ return
363
+
364
+ unique_labels = set(y_true)
365
+ if len(unique_labels) < 2:
366
+ only = "Correct" if 0 in unique_labels else "Hallucinated"
367
+ st.warning(
368
+ f"All {len(y_true)} completed samples were labelled **{only}** "
369
+ f"(label threshold = {label_threshold:.2f}). "
370
+ "Try lowering the label threshold so some responses are labelled as hallucinated, "
371
+ "or increase the number of questions."
372
+ )
373
+ # Still show the per-sample table so the user can inspect
374
+ with st.expander("Per-sample results", expanded=True):
375
+ st.dataframe(pd.DataFrame(details), use_container_width=True)
376
+ return
377
+
378
+ # ── Metrics ─────────────────────────────────────────────────────────────
379
+ y_pred = [1 if s > risk_threshold else 0 for s in y_scores]
380
+ auc_val = auc(*roc_curve(y_true, y_scores)[:2])
381
+ acc = accuracy_score(y_true, y_pred)
382
+ prec = precision_score(y_true, y_pred, zero_division=0)
383
+ rec = recall_score(y_true, y_pred, zero_division=0)
384
+ f1 = f1_score(y_true, y_pred, zero_division=0)
385
+ n_correct = y_true.count(0)
386
+ n_halluc = y_true.count(1)
387
+
388
+ st.success(
389
+ f"Evaluated **{len(y_true)}** questions — "
390
+ f"**{n_correct}** labelled Correct, **{n_halluc}** labelled Hallucinated."
391
+ )
392
+
393
+ st.subheader("Performance Metrics")
394
+ k1, k2, k3, k4, k5 = st.columns(5)
395
+ with k1:
396
+ metric_card("AUC", f"{auc_val:.4f}", "Area under ROC", "#0284c7")
397
+ with k2:
398
+ metric_card("Accuracy", f"{acc:.2%}", "(TP+TN) / Total", "#60a5fa")
399
+ with k3:
400
+ metric_card("Precision", f"{prec:.2%}", "TP / (TP+FP)", "#34d399")
401
+ with k4:
402
+ metric_card("Recall", f"{rec:.2%}", "TP / (TP+FN)", "#f59e0b")
403
+ with k5:
404
+ metric_card("F1 Score", f"{f1:.2%}", "Harmonic mean P·R", "#ef4444")
405
+
406
+ col_roc, col_cm = st.columns([3, 2])
407
+ with col_roc:
408
+ st.plotly_chart(plot_roc(y_true, y_scores, auc_val), use_container_width=True)
409
+ with col_cm:
410
+ st.plotly_chart(plot_confusion(y_true, y_pred), use_container_width=True)
411
+
412
+ st.plotly_chart(plot_risk_distribution(details), use_container_width=True)
413
+
414
+ with st.expander("Per-sample results", expanded=False):
415
+ st.dataframe(pd.DataFrame(details), use_container_width=True)
416
+
417
+ st.caption(
418
+ "The model's full analysis pipeline (EigenScore + Stability + Grounding + External) "
419
+ "is run for each question. final_risk is used as the ROC classifier score. "
420
+ "The true label is determined by comparing the model's own generated response "
421
+ f"to the CSV ground truth (label threshold = {label_threshold:.2f})."
422
+ )
ui_pages/page_explanation.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Page 2 - Explanation Page.
3
+ Converts raw metrics into user-friendly reasoning.
4
+ """
5
+
6
+ import math
7
+
8
+ import streamlit as st
9
+
10
+
11
+ def _eigen_level(eigen_score: float) -> str:
12
+ norm = 1.0 / (1.0 + math.exp(-eigen_score / max(1.0, abs(eigen_score) + 1e-9)))
13
+ return "High" if norm > 0.5 else "Low"
14
+
15
+
16
+ def build_reasons(results: dict) -> list[str]:
17
+ reasons = []
18
+ eigen_score = results["eigen"]["eigen_score"]
19
+ stability = results["stability"]["stability_score"]
20
+ grounding = results["grounding"]["grounding_score"]
21
+ ext_sim = results["external"]["external_consistency"]
22
+
23
+ if _eigen_level(eigen_score) == "High":
24
+ reasons.append("The model gave different answers across samples, which suggests uncertainty.")
25
+ else:
26
+ reasons.append("The model gave fairly consistent answers across samples, which suggests confidence.")
27
+
28
+ if stability < 0.5:
29
+ reasons.append("Its internal reasoning shifted noticeably across layers, so the answer path was unstable.")
30
+ else:
31
+ reasons.append("Its internal reasoning stayed relatively stable across layers.")
32
+
33
+ if grounding < 0.5:
34
+ reasons.append("The response was only weakly grounded in the original question.")
35
+ else:
36
+ reasons.append("The response stayed well grounded in the original question.")
37
+
38
+ if ext_sim < 0.5:
39
+ reasons.append("The answer did not match known facts strongly enough.")
40
+ else:
41
+ reasons.append("The answer matched the reference facts reasonably well.")
42
+
43
+ return reasons
44
+
45
+
46
+ def classify_verbose(results: dict) -> tuple[str, str, str]:
47
+ eigen_score = results["eigen"]["eigen_score"]
48
+ ext_sim = results["external"]["external_consistency"]
49
+
50
+ norm_eigen = 1.0 / (1.0 + math.exp(-eigen_score / max(1.0, abs(eigen_score) + 1e-9)))
51
+ eigen_high = norm_eigen > 0.5
52
+
53
+ if ext_sim > 0.5:
54
+ return (
55
+ "This answer appears to be correct and reliable.",
56
+ "Reliable",
57
+ "The model's answer aligns with known reference information and did not show strong warning signs internally.",
58
+ )
59
+ if eigen_high:
60
+ return (
61
+ "This answer looks uncertain.",
62
+ "Uncertain Hallucination",
63
+ "The model varied across samples and the result also failed to match the reference facts closely.",
64
+ )
65
+ return (
66
+ "This answer is likely incorrect despite sounding confident.",
67
+ "Confident but Incorrect Answer",
68
+ "The model stayed internally consistent, but the answer still did not line up with the reference facts.",
69
+ )
70
+
71
+
72
+ def _metric_bar(label: str, value: float, color: str, tooltip: str):
73
+ pct = round(value * 100, 1)
74
+ st.markdown(
75
+ f"""
76
+ <div style='margin-bottom:14px;'>
77
+ <div style='display:flex; justify-content:space-between; margin-bottom:4px;'>
78
+ <span style='color:#334155; font-size:0.9rem; font-weight:600;'>{label}</span>
79
+ <span style='color:#0f172a; font-size:0.9rem; font-weight:700;'>{pct}%</span>
80
+ </div>
81
+ <div class='meter-wrap'>
82
+ <div class='meter-fill' style='width:{pct}%; background:{color};'></div>
83
+ </div>
84
+ <span style='color:rgba(15,23,42,0.55); font-size:0.78rem;'>{tooltip}</span>
85
+ </div>
86
+ """,
87
+ unsafe_allow_html=True,
88
+ )
89
+
90
+
91
+ def render():
92
+ st.markdown(
93
+ """
94
+ <div class='hero'>
95
+ <h1>Plain-Language Explanation</h1>
96
+ <p>What the system found, explained in simple terms.</p>
97
+ </div>
98
+ """,
99
+ unsafe_allow_html=True,
100
+ )
101
+
102
+ results = st.session_state.get("last_results")
103
+ prompt = st.session_state.get("last_prompt", "")
104
+
105
+ if results is None:
106
+ st.markdown(
107
+ """
108
+ <div class='card' style='text-align:center; padding:2.5rem;'>
109
+ <h3 style='color:#0369a1;'>No Analysis Yet</h3>
110
+ <p style='color:rgba(15,23,42,0.55);'>
111
+ Run an analysis on the <strong>Analyzer</strong> page first, then come back here.
112
+ </p>
113
+ </div>""",
114
+ unsafe_allow_html=True,
115
+ )
116
+ return
117
+
118
+ headline, sub_label, conclusion = classify_verbose(results)
119
+ reasons = build_reasons(results)
120
+
121
+ st.markdown(
122
+ f"""
123
+ <div class='card'>
124
+ <p style='color:#0369a1; font-size:0.8rem; margin:0 0 4px; font-weight:700;'>YOUR QUESTION</p>
125
+ <p style='color:#0f172a; margin:0; font-size:1rem;'>"{prompt}"</p>
126
+ </div>
127
+ """,
128
+ unsafe_allow_html=True,
129
+ )
130
+
131
+ verdict_color = {
132
+ "Reliable": "#059669",
133
+ "Uncertain Hallucination": "#d97706",
134
+ "Confident but Incorrect Answer": "#dc2626",
135
+ }.get(sub_label, "#0369a1")
136
+
137
+ st.markdown(
138
+ f"""
139
+ <div class='card' style='border-left: 4px solid {verdict_color};'>
140
+ <h3 style='color:#0f172a; margin:0 0 6px;'>{headline}</h3>
141
+ <span style='background:{verdict_color}22; color:{verdict_color};
142
+ border:1px solid {verdict_color}55; padding:4px 14px;
143
+ border-radius:30px; font-size:0.85rem; font-weight:700;'>
144
+ {sub_label}
145
+ </span>
146
+ </div>
147
+ """,
148
+ unsafe_allow_html=True,
149
+ )
150
+
151
+ st.subheader("Reasons")
152
+ for reason in reasons:
153
+ st.markdown(f"<div class='reason-item'>{reason}</div>", unsafe_allow_html=True)
154
+
155
+ st.subheader("What the Numbers Mean")
156
+
157
+ eigen_score = results["eigen"]["eigen_score"]
158
+ stability = results["stability"]["stability_score"]
159
+ grounding = results["grounding"]["grounding_score"]
160
+ ext_sim = results["external"]["external_consistency"]
161
+ norm_eigen = 1.0 / (1.0 + math.exp(-eigen_score / max(1.0, abs(eigen_score) + 1e-9)))
162
+
163
+ col1, col2 = st.columns(2)
164
+ with col1:
165
+ _metric_bar(
166
+ "Answer Consistency (1 - EigenScore)",
167
+ 1 - norm_eigen,
168
+ "linear-gradient(90deg,#0284c7,#38bdf8)",
169
+ "Higher means the model repeated itself more consistently across samples.",
170
+ )
171
+ _metric_bar(
172
+ "Layer Stability",
173
+ stability,
174
+ "linear-gradient(90deg,#2563eb,#60a5fa)",
175
+ "Higher means the hidden-state trajectory changed less from layer to layer.",
176
+ )
177
+ with col2:
178
+ _metric_bar(
179
+ "Grounding",
180
+ grounding,
181
+ "linear-gradient(90deg,#0f766e,#2dd4bf)",
182
+ "Higher means the answer stayed closer to the original question.",
183
+ )
184
+ _metric_bar(
185
+ "Factual Similarity",
186
+ ext_sim,
187
+ "linear-gradient(90deg,#059669,#34d399)",
188
+ "Higher means the answer better matched the reference ground truth (TruthfulQA, CoQA, SQuAD, NQ, TriviaQA).",
189
+ )
190
+
191
+ st.markdown(
192
+ f"""
193
+ <div class='card' style='margin-top:1rem; border-top:3px solid {verdict_color};'>
194
+ <p style='color:#0369a1; font-weight:700; margin:0 0 6px;'>Conclusion</p>
195
+ <p style='color:#0f172a; margin:0; line-height:1.6;'>{conclusion}</p>
196
+ </div>
197
+ """,
198
+ unsafe_allow_html=True,
199
+ )
200
+
201
+ with st.expander("Metric Glossary"):
202
+ st.markdown(
203
+ """
204
+ | Metric | When High | When Low |
205
+ |---|---|---|
206
+ | **EigenScore** | Model gave more varied answers | Model gave more consistent answers |
207
+ | **Stability** | Reasoning stayed stable across layers | Reasoning changed more across layers |
208
+ | **Grounding** | Answer stayed connected to the question | Answer drifted away from the question |
209
+ | **External Similarity** | Answer matched known facts | Answer did not match known facts |
210
+ """
211
+ )
ui_pages/page_history.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Page 5 - Analysis History.
3
+ Tracks past analyses in session state.
4
+ """
5
+
6
+ import json
7
+ import math
8
+
9
+ import pandas as pd
10
+ import plotly.graph_objects as go
11
+ import streamlit as st
12
+
13
+
14
+ LIGHT_LAYOUT = dict(
15
+ paper_bgcolor="rgba(0,0,0,0)",
16
+ plot_bgcolor="rgba(0,0,0,0.03)",
17
+ font=dict(color="#475569"),
18
+ margin=dict(t=50, b=40, l=50, r=20),
19
+ )
20
+
21
+
22
+ def _classify(final_risk: float, eigen_score: float, ext_sim: float) -> tuple[str, str]:
23
+ norm_eigen = 1.0 / (1.0 + math.exp(-eigen_score / max(1.0, abs(eigen_score) + 1e-9)))
24
+ eigen_high = norm_eigen > 0.5
25
+ if ext_sim > 0.5:
26
+ return "Reliable", "badge-reliable"
27
+ if eigen_high:
28
+ return "Uncertain", "badge-uncertain"
29
+ return "Hallucination", "badge-confident-hall"
30
+
31
+
32
+ def _trend_chart(history: list) -> go.Figure:
33
+ labels = [f"#{i + 1}" for i in range(len(history))]
34
+ risks = [item["final_risk"] for item in history]
35
+ stabilities = [item["stability"] for item in history]
36
+
37
+ fig = go.Figure()
38
+ fig.add_trace(
39
+ go.Scatter(
40
+ x=labels,
41
+ y=risks,
42
+ mode="lines+markers",
43
+ name="Final Risk",
44
+ line=dict(color="#ef4444", width=2),
45
+ marker=dict(size=8),
46
+ hovertemplate="Run %{x}<br>Risk: %{y:.4f}<extra></extra>",
47
+ )
48
+ )
49
+ fig.add_trace(
50
+ go.Scatter(
51
+ x=labels,
52
+ y=stabilities,
53
+ mode="lines+markers",
54
+ name="Stability",
55
+ line=dict(color="#60a5fa", width=2, dash="dot"),
56
+ marker=dict(size=6),
57
+ hovertemplate="Run %{x}<br>Stability: %{y:.4f}<extra></extra>",
58
+ )
59
+ )
60
+ fig.add_hline(
61
+ y=0.5,
62
+ line_dash="dash",
63
+ line_color="#f59e0b",
64
+ annotation_text="Risk = 0.5",
65
+ annotation_font_color="#f59e0b",
66
+ )
67
+ fig.update_layout(
68
+ title=dict(text="Risk Score Trend Across Runs", font=dict(color="#111827", size=14)),
69
+ xaxis=dict(title="Run"),
70
+ yaxis=dict(title="Score", range=[0, 1.05]),
71
+ legend=dict(font=dict(color="#475569")),
72
+ height=320,
73
+ **LIGHT_LAYOUT,
74
+ )
75
+ return fig
76
+
77
+
78
+ def _radar_comparison(a: dict, b: dict, label_a: str, label_b: str) -> go.Figure:
79
+ categories = ["Consistency", "Stability", "Grounding", "Ext. Similarity", "Confidence"]
80
+
81
+ def values(item):
82
+ norm = 1.0 / (1.0 + math.exp(-item["eigen_score"] / max(1.0, abs(item["eigen_score"]) + 1e-9)))
83
+ return [
84
+ 1 - norm,
85
+ item["stability"],
86
+ item["grounding"],
87
+ item["ext_sim"],
88
+ 1 - item["final_risk"],
89
+ ]
90
+
91
+ values_a = values(a)
92
+ values_b = values(b)
93
+ fig = go.Figure()
94
+ fig.add_trace(
95
+ go.Scatterpolar(
96
+ r=values_a + [values_a[0]],
97
+ theta=categories + [categories[0]],
98
+ fill="toself",
99
+ fillcolor="rgba(2,132,199,0.14)",
100
+ line=dict(color="#0284c7", width=2),
101
+ name=label_a,
102
+ )
103
+ )
104
+ fig.add_trace(
105
+ go.Scatterpolar(
106
+ r=values_b + [values_b[0]],
107
+ theta=categories + [categories[0]],
108
+ fill="toself",
109
+ fillcolor="rgba(16,185,129,0.12)",
110
+ line=dict(color="#10b981", width=2),
111
+ name=label_b,
112
+ )
113
+ )
114
+ fig.update_layout(
115
+ polar=dict(
116
+ bgcolor="rgba(0,0,0,0.03)",
117
+ radialaxis=dict(visible=True, range=[0, 1], tickfont=dict(color="#475569")),
118
+ angularaxis=dict(tickfont=dict(color="#111827")),
119
+ ),
120
+ height=400,
121
+ showlegend=True,
122
+ title=dict(text="Metric Comparison", font=dict(color="#111827", size=14)),
123
+ legend=dict(font=dict(color="#475569")),
124
+ **{k: v for k, v in LIGHT_LAYOUT.items() if k != "plot_bgcolor"},
125
+ )
126
+ return fig
127
+
128
+
129
+ def render():
130
+ st.markdown(
131
+ """
132
+ <div class='hero'>
133
+ <h1>Analysis History</h1>
134
+ <p>Past runs in this session, with trends and side-by-side comparison.</p>
135
+ </div>
136
+ """,
137
+ unsafe_allow_html=True,
138
+ )
139
+
140
+ history = st.session_state.get("analysis_history", [])
141
+
142
+ if not history:
143
+ st.markdown(
144
+ """
145
+ <div class='card' style='text-align:center; padding:2.5rem;'>
146
+ <h3 style='color:#0369a1;'>No History Yet</h3>
147
+ <p style='color:rgba(15,23,42,0.55);'>
148
+ Run at least one analysis on the <strong>Analyzer</strong> page first.
149
+ </p>
150
+ </div>""",
151
+ unsafe_allow_html=True,
152
+ )
153
+ return
154
+
155
+ st.subheader(f"Session Summary - {len(history)} run(s)")
156
+
157
+ rows = []
158
+ for i, item in enumerate(history):
159
+ label, _ = _classify(item["final_risk"], item["eigen_score"], item["ext_sim"])
160
+ rows.append(
161
+ {
162
+ "Run": f"#{i + 1}",
163
+ "Prompt": item["prompt"][:60] + ("..." if len(item["prompt"]) > 60 else ""),
164
+ "Classification": label,
165
+ "Final Risk": round(item["final_risk"], 4),
166
+ "EigenScore": round(item["eigen_score"], 4),
167
+ "Stability": round(item["stability"], 4),
168
+ "Grounding": round(item["grounding"], 4),
169
+ "Ext. Sim": round(item["ext_sim"], 4),
170
+ "Confidence %": round((1 - item["final_risk"]) * 100, 1),
171
+ "GT Source": item.get("gt_source", "-"),
172
+ "Timestamp": item.get("timestamp", "-"),
173
+ }
174
+ )
175
+
176
+ df = pd.DataFrame(rows)
177
+ st.dataframe(df, use_container_width=True)
178
+
179
+ if len(history) > 1:
180
+ st.plotly_chart(_trend_chart(history), use_container_width=True)
181
+ else:
182
+ st.info("Run at least 2 analyses to see the trend chart.")
183
+
184
+ st.markdown("<hr style='border-color:rgba(15,23,42,0.1);'/>", unsafe_allow_html=True)
185
+ st.subheader("Side-by-Side Comparison")
186
+
187
+ run_options = [f"#{i + 1} - {item['prompt'][:50]}..." for i, item in enumerate(history)]
188
+
189
+ if len(history) < 2:
190
+ st.info("Run at least 2 analyses to enable comparison.")
191
+ else:
192
+ c1, c2 = st.columns(2)
193
+ with c1:
194
+ idx_a = st.selectbox("Run A", range(len(history)), format_func=lambda i: run_options[i], key="cmp_a")
195
+ with c2:
196
+ idx_b = st.selectbox(
197
+ "Run B",
198
+ range(len(history)),
199
+ format_func=lambda i: run_options[i],
200
+ index=min(1, len(history) - 1),
201
+ key="cmp_b",
202
+ )
203
+
204
+ run_a = history[idx_a]
205
+ run_b = history[idx_b]
206
+
207
+ metrics_def = [
208
+ ("Final Risk", "final_risk", False),
209
+ ("EigenScore", "eigen_score", False),
210
+ ("Stability", "stability", True),
211
+ ("Grounding", "grounding", True),
212
+ ("Ext. Sim", "ext_sim", True),
213
+ ]
214
+
215
+ cols = st.columns(len(metrics_def))
216
+ for col, (name, key, high_good) in zip(cols, metrics_def):
217
+ value_a = run_a[key]
218
+ value_b = run_b[key]
219
+ delta = value_b - value_a
220
+ better = (delta > 0) == high_good
221
+ arrow = "Improved" if abs(delta) <= 0.001 else ("Up" if better else "Down")
222
+ col.markdown(
223
+ f"""
224
+ <div class='card' style='text-align:center;'>
225
+ <p style='color:rgba(0,0,0,0.45);font-size:0.75rem;margin:0 0 2px;font-weight:700;'>{name}</p>
226
+ <p style='color:#111827;font-size:1rem;font-weight:700;margin:0;'>
227
+ {value_a:.4f} -> {value_b:.4f}
228
+ </p>
229
+ <p style='font-size:0.85rem;margin:2px 0 0;'>{arrow} {delta:+.4f}</p>
230
+ </div>""",
231
+ unsafe_allow_html=True,
232
+ )
233
+
234
+ st.plotly_chart(
235
+ _radar_comparison(run_a, run_b, f"Run #{idx_a + 1}", f"Run #{idx_b + 1}"),
236
+ use_container_width=True,
237
+ )
238
+
239
+ st.markdown("<hr style='border-color:rgba(15,23,42,0.1);'/>", unsafe_allow_html=True)
240
+ st.subheader("Export")
241
+ c1, c2, c3 = st.columns(3)
242
+
243
+ with c1:
244
+ csv = df.to_csv(index=False).encode("utf-8")
245
+ st.download_button(
246
+ "Download CSV",
247
+ csv,
248
+ file_name="halluciScan_history.csv",
249
+ mime="text/csv",
250
+ use_container_width=True,
251
+ key="dl_csv",
252
+ )
253
+
254
+ with c2:
255
+ json_data = json.dumps(history, indent=2, default=str).encode("utf-8")
256
+ st.download_button(
257
+ "Download JSON",
258
+ json_data,
259
+ file_name="halluciScan_history.json",
260
+ mime="application/json",
261
+ use_container_width=True,
262
+ key="dl_json",
263
+ )
264
+
265
+ with c3:
266
+ if st.button("Clear History", use_container_width=True, key="clear_hist"):
267
+ st.session_state["analysis_history"] = []
268
+ st.rerun()
ui_pages/page_metrics.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Page 4 - Detailed Metrics.
3
+ Shows EigenScore, Stability, Grounding, and External Similarity with breakdowns.
4
+ """
5
+
6
+ import json
7
+ import math
8
+
9
+ import plotly.graph_objects as go
10
+ import streamlit as st
11
+
12
+
13
+ LIGHT_LAYOUT = dict(
14
+ paper_bgcolor="rgba(0,0,0,0)",
15
+ plot_bgcolor="rgba(0,0,0,0.03)",
16
+ font=dict(color="#4b5563"),
17
+ margin=dict(t=50, b=40, l=50, r=20),
18
+ )
19
+
20
+
21
+ def _norm_eigen(eigen_score: float) -> float:
22
+ return 1.0 / (1.0 + math.exp(-eigen_score / max(1.0, abs(eigen_score) + 1e-9)))
23
+
24
+
25
+ def _pill(text: str, color: str) -> str:
26
+ return (
27
+ f"<span style='background:{color}22; color:{color}; border:1px solid {color}55; "
28
+ f"padding:3px 12px; border-radius:30px; font-size:0.82rem; font-weight:700;'>{text}</span>"
29
+ )
30
+
31
+
32
+ def _status_pill(value: float, high_good: bool = True) -> str:
33
+ good = value > 0.65 if high_good else value < 0.35
34
+ medium = 0.35 <= value <= 0.65
35
+ if good:
36
+ return _pill("Good", "#10b981")
37
+ if medium:
38
+ return _pill("Medium", "#f59e0b")
39
+ return _pill("Poor", "#ef4444")
40
+
41
+
42
+ def radar_chart(metrics: dict) -> go.Figure:
43
+ categories = ["Consistency", "Stability", "Grounding", "External Similarity", "Confidence"]
44
+ values = [
45
+ metrics["consistency"],
46
+ metrics["stability"],
47
+ metrics["grounding"],
48
+ metrics["ext_sim"],
49
+ metrics["confidence"] / 100,
50
+ ]
51
+ values_closed = values + [values[0]]
52
+ cats_closed = categories + [categories[0]]
53
+
54
+ fig = go.Figure()
55
+ fig.add_trace(
56
+ go.Scatterpolar(
57
+ r=values_closed,
58
+ theta=cats_closed,
59
+ fill="toself",
60
+ fillcolor="rgba(2,132,199,0.16)",
61
+ line=dict(color="#0284c7", width=2),
62
+ name="Metrics",
63
+ )
64
+ )
65
+ fig.update_layout(
66
+ polar=dict(
67
+ bgcolor="rgba(0,0,0,0.03)",
68
+ radialaxis=dict(
69
+ visible=True,
70
+ range=[0, 1],
71
+ tickfont=dict(color="#4b5563"),
72
+ gridcolor="rgba(0,0,0,0.1)",
73
+ ),
74
+ angularaxis=dict(
75
+ tickfont=dict(color="#111827"),
76
+ gridcolor="rgba(0,0,0,0.1)"),
77
+ ),
78
+ height=380,
79
+ showlegend=False,
80
+ title=dict(text="Metric Radar", font=dict(color="#111827", size=14)),
81
+ **{k: v for k, v in LIGHT_LAYOUT.items() if k != "plot_bgcolor"},
82
+ )
83
+ return fig
84
+
85
+
86
+ def layer_stability_chart(layer_sims: list) -> go.Figure:
87
+ x_values = [f"L{i}->L{i + 1}" for i in range(len(layer_sims))]
88
+ fig = go.Figure(
89
+ go.Scatter(
90
+ x=x_values,
91
+ y=layer_sims,
92
+ mode="lines+markers",
93
+ line=dict(color="#60a5fa", width=2),
94
+ marker=dict(size=7, color="#60a5fa", line=dict(color="#1e3a5f", width=1.5)),
95
+ fill="tozeroy",
96
+ fillcolor="rgba(96,165,250,0.08)",
97
+ hovertemplate="%{x}: %{y:.4f}<extra></extra>",
98
+ )
99
+ )
100
+ fig.add_hline(
101
+ y=0.5,
102
+ line_dash="dash",
103
+ line_color="#f59e0b",
104
+ annotation_text="Stability Threshold (0.5)",
105
+ annotation_font_color="#f59e0b",
106
+ )
107
+ fig.update_layout(
108
+ title=dict(
109
+ text="Layer-wise Stability",
110
+ font=dict(color="#111827", size=13),
111
+ ),
112
+ xaxis=dict(title="Layer transition"),
113
+ yaxis=dict(title="Similarity", range=[0, 1.05]),
114
+ height=300,
115
+ **LIGHT_LAYOUT,
116
+ )
117
+ return fig
118
+
119
+
120
+ def similarity_bar(similarities: list) -> go.Figure:
121
+ colors = ["#10b981" if score > 0.5 else "#ef4444" for score in similarities]
122
+ fig = go.Figure(
123
+ go.Bar(
124
+ x=[f"Response {i + 1}" for i in range(len(similarities))],
125
+ y=similarities,
126
+ marker_color=colors,
127
+ text=[f"{score:.3f}" for score in similarities],
128
+ textposition="outside",
129
+ textfont=dict(color="#111827"),
130
+ )
131
+ )
132
+ fig.add_hline(
133
+ y=0.5,
134
+ line_dash="dash",
135
+ line_color="#f59e0b",
136
+ annotation_text="Similarity threshold (0.5)",
137
+ annotation_font_color="#f59e0b",
138
+ )
139
+ fig.update_layout(
140
+ title=dict(text="Per-response Similarity to Ground Truth", font=dict(color="#111827", size=13)),
141
+ yaxis=dict(range=[0, 1.15]),
142
+ height=300,
143
+ **LIGHT_LAYOUT,
144
+ )
145
+ return fig
146
+
147
+
148
+ def internal_risk_breakdown(results: dict) -> go.Figure:
149
+ ir = results["internal_risk"]
150
+ labels = ["EigenScore Component", "Stability Component", "Grounding Component"]
151
+ values = [
152
+ ir["eigen_score_component"],
153
+ ir["stability_component"],
154
+ ir["grounding_component"],
155
+ ]
156
+ colors = ["#0284c7", "#60a5fa", "#34d399"]
157
+ fig = go.Figure(
158
+ go.Bar(
159
+ x=labels,
160
+ y=values,
161
+ marker_color=colors,
162
+ text=[f"{value:.4f}" for value in values],
163
+ textposition="outside",
164
+ textfont=dict(color="#111827"),
165
+ )
166
+ )
167
+ fig.update_layout(
168
+ title=dict(text="Internal Risk Component Breakdown", font=dict(color="#111827", size=13)),
169
+ yaxis=dict(title="Risk contribution"),
170
+ height=300,
171
+ **LIGHT_LAYOUT,
172
+ )
173
+ return fig
174
+
175
+
176
+ def weights_pie(results: dict) -> go.Figure:
177
+ alpha = results["weights"]["alpha"]
178
+ beta = results["weights"]["beta"]
179
+ internal_risk = results["internal_risk"]["internal_risk"]
180
+ external_risk = results["external"]["external_risk"]
181
+ fig = go.Figure(
182
+ go.Pie(
183
+ labels=["Internal Risk (alpha)", "External Risk (beta)"],
184
+ values=[alpha * internal_risk, beta * external_risk],
185
+ marker=dict(colors=["#0284c7", "#0f766e"], line=dict(color="rgba(0,0,0,0)", width=0)),
186
+ hole=0.5,
187
+ textfont=dict(color="#111827"),
188
+ )
189
+ )
190
+ fig.update_layout(
191
+ title=dict(text="Final Risk Composition", font=dict(color="#111827", size=13)),
192
+ legend=dict(font=dict(color="#4b5563")),
193
+ height=300,
194
+ **{k: v for k, v in LIGHT_LAYOUT.items() if k != "plot_bgcolor"},
195
+ )
196
+ return fig
197
+
198
+
199
+ def _detail_card(title: str, value_str: str, pill_html: str, meaning: str, border_color: str):
200
+ st.markdown(
201
+ f"""
202
+ <div class='card' style='border-left:4px solid {border_color};'>
203
+ <div style='display:flex; justify-content:space-between; align-items:center;'>
204
+ <span style='color:#111827; font-weight:700; font-size:1rem;'>{title}</span>
205
+ {pill_html}
206
+ </div>
207
+ <p style='color:#0369a1; font-size:1.5rem; font-weight:700; margin:4px 0;'>{value_str}</p>
208
+ <p style='color:rgba(0,0,0,0.6); font-size:0.85rem; margin:0;'>{meaning}</p>
209
+ </div>""",
210
+ unsafe_allow_html=True,
211
+ )
212
+
213
+
214
+ def render():
215
+ st.markdown(
216
+ """
217
+ <div class='hero'>
218
+ <h1>Detailed Metrics</h1>
219
+ <p>Advanced view of EigenScore, Stability, Grounding, and External Similarity.</p>
220
+ </div>
221
+ """,
222
+ unsafe_allow_html=True,
223
+ )
224
+
225
+ results = st.session_state.get("last_results")
226
+
227
+ if results is None:
228
+ st.markdown(
229
+ """
230
+ <div class='card' style='text-align:center; padding:2.5rem;'>
231
+ <h3 style='color:#0369a1;'>No Analysis Yet</h3>
232
+ <p style='color:rgba(0,0,0,0.6);'>
233
+ Run an analysis on the <strong>Analyzer</strong> page first.
234
+ </p>
235
+ </div>""",
236
+ unsafe_allow_html=True,
237
+ )
238
+ return
239
+
240
+ eigen_score = results["eigen"]["eigen_score"]
241
+ stability = results["stability"]["stability_score"]
242
+ grounding = results["grounding"]["grounding_score"]
243
+ ext_sim = results["external"]["external_consistency"]
244
+ final_risk = results["final_risk"]
245
+ confidence = round((1 - final_risk) * 100, 1)
246
+ norm_eigen = _norm_eigen(eigen_score)
247
+ consistency = 1 - norm_eigen
248
+
249
+ c1, c2 = st.columns(2)
250
+ with c1:
251
+ _detail_card(
252
+ "EigenScore (raw)",
253
+ f"{eigen_score:.4f}",
254
+ _status_pill(consistency, high_good=True),
255
+ "Lower and more negative values usually mean the sampled answers stayed closer together.",
256
+ "#0284c7",
257
+ )
258
+ _detail_card(
259
+ "Grounding Score",
260
+ f"{grounding:.4f}",
261
+ _status_pill(grounding, high_good=True),
262
+ "Measures how strongly the generated answer attends back to the question tokens.",
263
+ "#34d399",
264
+ )
265
+ with c2:
266
+ _detail_card(
267
+ "Stability Score",
268
+ f"{stability:.4f}",
269
+ _status_pill(stability, high_good=True),
270
+ "Measures similarity between adjacent transformer layers across the response.",
271
+ "#60a5fa",
272
+ )
273
+ _detail_card(
274
+ "External Similarity",
275
+ f"{ext_sim:.4f}",
276
+ _status_pill(ext_sim, high_good=True),
277
+ "Measures how closely the generated responses match the reference answer.",
278
+ "#f59e0b",
279
+ )
280
+
281
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
282
+ fr1, fr2, fr3 = st.columns(3)
283
+ fr1.metric("Final Risk Score", f"{final_risk:.4f}")
284
+ fr2.metric("Confidence", f"{confidence}%")
285
+ fr3.metric("Samples Generated", results["eigen"]["num_responses"])
286
+ fr4, fr5 = st.columns(2)
287
+ fr4.metric("Internal Risk", f"{results['internal_risk']['internal_risk']:.4f}")
288
+ fr5.metric("External Risk", f"{results['external']['external_risk']:.4f}")
289
+ st.markdown("</div>", unsafe_allow_html=True)
290
+
291
+ col_rad, col_pie = st.columns(2)
292
+ with col_rad:
293
+ st.plotly_chart(
294
+ radar_chart(
295
+ {
296
+ "consistency": consistency,
297
+ "stability": stability,
298
+ "grounding": grounding,
299
+ "ext_sim": ext_sim,
300
+ "confidence": confidence,
301
+ }
302
+ ),
303
+ use_container_width=True,
304
+ )
305
+ with col_pie:
306
+ st.plotly_chart(weights_pie(results), use_container_width=True)
307
+
308
+ st.plotly_chart(internal_risk_breakdown(results), use_container_width=True)
309
+
310
+ if "layer_similarities" in results["stability"]:
311
+ st.plotly_chart(
312
+ layer_stability_chart(results["stability"]["layer_similarities"]),
313
+ use_container_width=True,
314
+ )
315
+
316
+ if results["external"]["ground_truth"] != "N/A":
317
+ st.plotly_chart(
318
+ similarity_bar(results["external"]["similarities"]),
319
+ use_container_width=True,
320
+ )
321
+ st.markdown(
322
+ f"""
323
+ <div class='card'>
324
+ <p style='color:#0369a1; font-weight:700; margin:0 0 4px;'>Ground Truth Used</p>
325
+ <p style='color:#111827; margin:0;'>{results['external']['ground_truth']}</p>
326
+ </div>""",
327
+ unsafe_allow_html=True,
328
+ )
329
+
330
+ with st.expander("Feature Clipping (INSIDE paper)"):
331
+ clipped = results["eigen"].get("clipping_applied", False)
332
+ st.markdown(
333
+ f"""
334
+ - **Clipping applied:** {"Yes" if clipped else "No (memory bank was empty)"}
335
+ - Hidden-state activations are clipped per feature dimension using percentile thresholds
336
+ derived from the accumulated memory bank.
337
+ - This helps keep outlier activations from dominating the EigenScore.
338
+ - Reference: *INSIDE: LLMs' Internal States Are Good Indicators of Factual Accuracy* (ICLR 2024)
339
+ """
340
+ )
341
+
342
+ with st.expander("Raw Results (JSON)"):
343
+ safe = {
344
+ "prompt": results["prompt"],
345
+ "final_risk": results["final_risk"],
346
+ "eigen_score": results["eigen"]["eigen_score"],
347
+ "stability": results["stability"]["stability_score"],
348
+ "grounding": results["grounding"]["grounding_score"],
349
+ "ext_sim": results["external"]["external_consistency"],
350
+ "weights": results["weights"],
351
+ "internal_risk": results["internal_risk"],
352
+ "external_risk": results["external"]["external_risk"],
353
+ }
354
+ st.code(json.dumps(safe, indent=2), language="json")