Spaces:
Sleeping
Sleeping
Commit ·
f1cdb6f
0
Parent(s):
may be final
Browse files- .gitignore +54 -0
- ARCHITECTURE.md +195 -0
- INSTALL_ANACONDA.md +100 -0
- PROJECT_SUMMARY.md +228 -0
- QUICKSTART.md +115 -0
- README.md +224 -0
- SETUP.md +111 -0
- analyzer.py +270 -0
- app.py +240 -0
- dataset_loader.py +85 -0
- example.py +51 -0
- external_verifier.py +241 -0
- generation_validation.csv +0 -0
- gllm.txt +0 -0
- gllm2.txt +159 -0
- install.bat +39 -0
- internal_metrics.py +500 -0
- main.py +115 -0
- model_loader.py +136 -0
- multiple_choice_validation.csv +0 -0
- ollama_loader.py +207 -0
- pyrightconfig.json +11 -0
- requirements.txt +12 -0
- run_ui.bat +11 -0
- ui_pages/__init__.py +1 -0
- ui_pages/page_analyzer.py +282 -0
- ui_pages/page_evaluation.py +422 -0
- ui_pages/page_explanation.py +211 -0
- ui_pages/page_history.py +268 -0
- ui_pages/page_metrics.py +354 -0
.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")
|