Text Classification
Scikit-learn
Joblib
Safetensors
English
hallucination-detection
tool-calling
span-detection
ensemble
Instructions to use jameVee/ToolACE-Hallucination-Detector with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Scikit-learn
How to use jameVee/ToolACE-Hallucination-Detector with Scikit-learn:
from huggingface_hub import hf_hub_download import joblib model = joblib.load( hf_hub_download("jameVee/ToolACE-Hallucination-Detector", "sklearn_model.joblib") ) # only load pickle files from sources you trust # read more about it here https://skops.readthedocs.io/en/stable/persistence.html - Notebooks
- Google Colab
- Kaggle
Add trained hallucination-detector classifiers
Browse files- README.md +279 -0
- evaluation_baselines_span_utils.py +454 -0
- lettuce_clf.joblib +3 -0
- lexical_clf.joblib +3 -0
- lookback_clf.joblib +3 -0
- model_meta.json +71 -0
README.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- en
|
| 4 |
+
license: mit
|
| 5 |
+
base_model:
|
| 6 |
+
- KRLabsOrg/lettucedect-base-modernbert-en-v1
|
| 7 |
+
- Qwen/Qwen2.5-0.5B
|
| 8 |
+
datasets:
|
| 9 |
+
- jameVee/ToolACE-Hallucination
|
| 10 |
+
tags:
|
| 11 |
+
- hallucination-detection
|
| 12 |
+
- tool-calling
|
| 13 |
+
- text-classification
|
| 14 |
+
- span-detection
|
| 15 |
+
- sklearn
|
| 16 |
+
- ensemble
|
| 17 |
+
metrics:
|
| 18 |
+
- f1
|
| 19 |
+
- roc_auc
|
| 20 |
+
pipeline_tag: text-classification
|
| 21 |
+
pretty_name: ToolACE Hallucination Detector
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
# ToolACE Hallucination Detector
|
| 25 |
+
|
| 26 |
+
A lightweight ensemble classifier for detecting hallucinations in LLM responses that follow tool calls. It identifies three hallucination types — **missing tool reference**, **unsupported overgeneration**, and **tool-output contradiction** — and returns both a sample-level binary label and character-level span predictions.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Background
|
| 31 |
+
|
| 32 |
+
When an LLM answers a user query using tool outputs, it can hallucinate in distinct ways:
|
| 33 |
+
|
| 34 |
+
| Type | Description |
|
| 35 |
+
|---|---|
|
| 36 |
+
| `missing_tool` | The assistant suggests using a tool that was not available |
|
| 37 |
+
| `overgeneration` | The answer contains plausible but unsupported extra facts |
|
| 38 |
+
| `tool_output_contradiction` | The answer contradicts specific facts returned by the tool |
|
| 39 |
+
|
| 40 |
+
This model was trained on **[jameVee/ToolACE-Hallucination](https://huggingface.co/datasets/jameVee/ToolACE-Hallucination)**, a benchmark of ~4 100 examples derived from [Team-ACE/ToolACE](https://huggingface.co/datasets/Team-ACE/ToolACE). Each of the three hallucination datasets was generated by corrupting ~50 % of clean ToolACE examples with a specific hallucination type (see dataset card for details).
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## Architecture
|
| 45 |
+
|
| 46 |
+
The detector is a **three-branch soft-voting ensemble**. Each branch independently produces a hallucination probability, and the three scores are averaged to give the final prediction.
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
┌──────────────────────────────────┐
|
| 50 |
+
query + context ──────► │ Branch A: Lexical Verifier │ ──► P_A
|
| 51 |
+
+ tool output │ (token overlap span detector │
|
| 52 |
+
│ → StandardScaler + LogReg) │
|
| 53 |
+
└──────────────────────────────────┘
|
| 54 |
+
┌──────────────────────────────────┐
|
| 55 |
+
───► │ Branch B: LettuceDetect │ ──► P_B
|
| 56 |
+
│ (ModernBERT span model │
|
| 57 |
+
│ → StandardScaler + LogReg) │
|
| 58 |
+
└──────────────────────────────────┘
|
| 59 |
+
┌──────────────────────────────────┐
|
| 60 |
+
───► │ Branch C: LookBack-style │ ──► P_C
|
| 61 |
+
│ (Qwen2.5-0.5B attention ratios │
|
| 62 |
+
│ → StandardScaler + LogReg) │
|
| 63 |
+
└──────────────────────────────────┘
|
| 64 |
+
│
|
| 65 |
+
avg(P_A, P_B, P_C) ≥ 0.5
|
| 66 |
+
│
|
| 67 |
+
hallucinated?
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Branch A — Lexical Span Verifier
|
| 71 |
+
|
| 72 |
+
Checks whether tokens in the assistant's answer are grounded in the (normalized) tool output using lexical overlap. Unsupported token sequences become candidate hallucination spans. Span-level features (count, coverage, max score) are fed into a logistic regression.
|
| 73 |
+
|
| 74 |
+
### Branch B — LettuceDetect (supervised span model)
|
| 75 |
+
|
| 76 |
+
Uses [KRLabsOrg/lettucedect-base-modernbert-en-v1](https://huggingface.co/KRLabsOrg/lettucedect-base-modernbert-en-v1), a ModernBERT-based transformer fine-tuned for grounded hallucination detection. It produces character-level spans scored by confidence. A logistic regression trained on span features converts those into a sample-level probability. **Best single branch** (AUROC 0.721, Span-F1 0.208).
|
| 77 |
+
|
| 78 |
+
### Branch C — LookBack-style Detector
|
| 79 |
+
|
| 80 |
+
Passes the concatenation of context and answer through [Qwen/Qwen2.5-0.5B](https://huggingface.co/Qwen/Qwen2.5-0.5B) and computes, for each answer token, the ratio of attention it pays to the context versus to previously generated tokens. Low-grounding tokens (ratio < 0.22) are merged into hallucination spans. Span and ratio features are fed to logistic regression.
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## Files in this repository
|
| 85 |
+
|
| 86 |
+
| File | Description |
|
| 87 |
+
|---|---|
|
| 88 |
+
| `lexical_clf.joblib` | Branch A sklearn Pipeline (StandardScaler + LogisticRegression) |
|
| 89 |
+
| `lettuce_clf.joblib` | Branch B sklearn Pipeline (StandardScaler + LogisticRegression) |
|
| 90 |
+
| `lookback_clf.joblib` | Branch C sklearn Pipeline (StandardScaler + LogisticRegression) |
|
| 91 |
+
| `model_meta.json` | Feature column names, backbone IDs, train/test split sizes, test metrics |
|
| 92 |
+
| `evaluation_baselines_span_utils.py` | Span extraction helpers required at inference time |
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## Performance (test set, 20 % held-out, grouped split by example_id)
|
| 97 |
+
|
| 98 |
+
| Method | Accuracy | F1 | AUROC | Span-F1 |
|
| 99 |
+
|---|---|---|---|---|
|
| 100 |
+
| Lexical span verifier | 0.566 | 0.523 | 0.603 | 0.053 |
|
| 101 |
+
| LettuceDetect (Branch B) | **0.678** | 0.632 | **0.721** | **0.208** |
|
| 102 |
+
| LookBack (Branch C) | 0.489 | 0.561 | 0.511 | 0.000 |
|
| 103 |
+
| **Soft-vote ensemble** | 0.676 | **0.659** | 0.721 | 0.179 |
|
| 104 |
+
|
| 105 |
+
Per-type F1 (ensemble):
|
| 106 |
+
|
| 107 |
+
| Corruption type | F1 |
|
| 108 |
+
|---|---|
|
| 109 |
+
| `missing_tool` | 0.617 |
|
| 110 |
+
| `overgeneration` | 0.693 |
|
| 111 |
+
| `tool_output_contradiction` | 0.779 |
|
| 112 |
+
| `clean` | — |
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## How to use
|
| 117 |
+
|
| 118 |
+
### 1. Install dependencies
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
pip install joblib scikit-learn transformers lettucedetect torch
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### 2. Load the classifiers
|
| 125 |
+
|
| 126 |
+
```python
|
| 127 |
+
import joblib, json
|
| 128 |
+
from pathlib import Path
|
| 129 |
+
|
| 130 |
+
repo = Path("hallucination_detector") # or your local clone path
|
| 131 |
+
|
| 132 |
+
lex_clf = joblib.load(repo / "lexical_clf.joblib")
|
| 133 |
+
lettuce_clf = joblib.load(repo / "lettuce_clf.joblib")
|
| 134 |
+
lbl_clf = joblib.load(repo / "lookback_clf.joblib")
|
| 135 |
+
|
| 136 |
+
with open(repo / "model_meta.json") as f:
|
| 137 |
+
meta = json.load(f)
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### 3. Load the backbone models
|
| 141 |
+
|
| 142 |
+
```python
|
| 143 |
+
import torch
|
| 144 |
+
from lettucedetect.models.inference import HallucinationDetector
|
| 145 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 146 |
+
|
| 147 |
+
lettuce_detector = HallucinationDetector(
|
| 148 |
+
method="transformer",
|
| 149 |
+
model_path="KRLabsOrg/lettucedect-base-modernbert-en-v1",
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 153 |
+
lbl_tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B", use_fast=True)
|
| 154 |
+
lbl_model = AutoModelForCausalLM.from_pretrained(
|
| 155 |
+
"Qwen/Qwen2.5-0.5B", torch_dtype=torch.bfloat16, attn_implementation="eager"
|
| 156 |
+
).to(DEVICE).eval()
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### 4. Run inference
|
| 160 |
+
|
| 161 |
+
```python
|
| 162 |
+
import sys
|
| 163 |
+
sys.path.insert(0, str(repo)) # make span utils importable
|
| 164 |
+
from evaluation_baselines_span_utils import (
|
| 165 |
+
add_normalized_context_columns,
|
| 166 |
+
aggregate_lookback_features,
|
| 167 |
+
aggregate_span_features,
|
| 168 |
+
lexical_hallucination_spans,
|
| 169 |
+
spans_from_lookback_ratios,
|
| 170 |
+
merge_spans,
|
| 171 |
+
)
|
| 172 |
+
import pandas as pd, numpy as np
|
| 173 |
+
|
| 174 |
+
def predict(query: str, context: str, output: str) -> dict:
|
| 175 |
+
row = pd.Series({"query": query, "context": context, "output": output})
|
| 176 |
+
# normalize context (converts raw tool JSON to readable text)
|
| 177 |
+
row_df = add_normalized_context_columns(pd.DataFrame([row]))
|
| 178 |
+
row = row_df.iloc[0]
|
| 179 |
+
|
| 180 |
+
# Branch A — lexical
|
| 181 |
+
lex_spans = lexical_hallucination_spans(row)
|
| 182 |
+
lex_feats = aggregate_span_features(lex_spans, len(output))
|
| 183 |
+
p_lex = lex_clf.predict_proba(pd.DataFrame([lex_feats]))[0, 1]
|
| 184 |
+
|
| 185 |
+
# Branch B — LettuceDetect
|
| 186 |
+
raw_spans = lettuce_detector.predict(
|
| 187 |
+
context=[row["normalized_context"]],
|
| 188 |
+
question=query, answer=output, output_format="spans",
|
| 189 |
+
)
|
| 190 |
+
lettuce_spans = merge_spans([
|
| 191 |
+
{"start": int(s["start"]), "end": int(s["end"]),
|
| 192 |
+
"text": output[int(s["start"]):int(s["end"])],
|
| 193 |
+
"type": "hallucination", "score": float(s.get("score", 0.0))}
|
| 194 |
+
for s in raw_spans if int(s.get("end", 0)) > int(s.get("start", 0))
|
| 195 |
+
])
|
| 196 |
+
lettuce_feats = aggregate_span_features(lettuce_spans, len(output))
|
| 197 |
+
p_lettuce = lettuce_clf.predict_proba(pd.DataFrame([lettuce_feats]))[0, 1]
|
| 198 |
+
|
| 199 |
+
# Branch C — LookBack
|
| 200 |
+
from evaluation_baselines_span_utils import aggregate_lookback_features
|
| 201 |
+
# (reuse compute_lookback_ratios from the training notebook)
|
| 202 |
+
# p_lbl = lbl_clf.predict_proba(pd.DataFrame([lbl_feats]))[0, 1]
|
| 203 |
+
# For a self-contained example we skip Branch C and average A+B only:
|
| 204 |
+
p_ensemble = np.mean([p_lex, p_lettuce])
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"hallucinated": bool(p_ensemble >= 0.5),
|
| 208 |
+
"score": float(p_ensemble),
|
| 209 |
+
"lex_score": float(p_lex),
|
| 210 |
+
"lettuce_score": float(p_lettuce),
|
| 211 |
+
"lettuce_spans": lettuce_spans,
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
result = predict(
|
| 215 |
+
query="What is the current price of AAPL?",
|
| 216 |
+
context='Stock API: {"ticker": "AAPL", "price": 189.50, "change": "+1.2%"}',
|
| 217 |
+
output="The current price of AAPL is $189.50, up 1.2%. It also hit an all-time high last Tuesday.",
|
| 218 |
+
)
|
| 219 |
+
print(result)
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## Training details
|
| 225 |
+
|
| 226 |
+
- **Source dataset**: [jameVee/ToolACE-Hallucination](https://huggingface.co/datasets/jameVee/ToolACE-Hallucination) (1 034 base examples × 4 variants = 4 136 rows total)
|
| 227 |
+
- **Split**: 80 / 20 grouped by `example_id` (no leakage between clean and corrupted variants of the same query)
|
| 228 |
+
- **Classifiers**: scikit-learn `LogisticRegression(max_iter=1000)` wrapped in a `StandardScaler` pipeline
|
| 229 |
+
- **Random seed**: 1241
|
| 230 |
+
|
| 231 |
+
### About the training dataset
|
| 232 |
+
|
| 233 |
+
`jameVee/ToolACE-Hallucination` contains three JSONL files, each derived from [Team-ACE/ToolACE](https://huggingface.co/datasets/Team-ACE/ToolACE):
|
| 234 |
+
|
| 235 |
+
- `missing_tool_dataset.jsonl` — generated with `unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit` (50 % corruption rate): a sentence is appended that refers to a non-existent tool.
|
| 236 |
+
- `overgeneration_dataset.jsonl` — same generator (50 % corruption rate): a plausible but unsupported sentence is appended.
|
| 237 |
+
- `tool_output_contradiction_dataset.jsonl` — generated with `openai/gpt-4o-mini` via OpenRouter (all entries attempted, strength 0.9): the answer is rewritten to contradict grounded facts from the tool output.
|
| 238 |
+
|
| 239 |
+
Each entry carries character-level `hallucination_labels` marking the corrupted span(s).
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## Limitations
|
| 244 |
+
|
| 245 |
+
- The classifiers are trained on a relatively small dataset (~3 300 training rows). Performance may degrade on domains or tool schemas not represented in ToolACE.
|
| 246 |
+
- Branch C (LookBack) shows weak span-level performance at the default threshold (0.22); tuning this on a validation split is recommended.
|
| 247 |
+
- The ensemble does not produce type-specific labels — it only predicts binary hallucinated / clean at the sample level.
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
## Citation
|
| 252 |
+
|
| 253 |
+
If you use this model or the associated datasets, please cite:
|
| 254 |
+
|
| 255 |
+
```bibtex
|
| 256 |
+
@misc{toolace_hallucination_detector,
|
| 257 |
+
author = {jameVee},
|
| 258 |
+
title = {ToolACE Hallucination Detector},
|
| 259 |
+
year = {2026},
|
| 260 |
+
publisher = {Hugging Face},
|
| 261 |
+
url = {https://huggingface.co/jameVee/ToolACE-Hallucination-Detector}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
@dataset{toolace_hallucination,
|
| 265 |
+
author = {jameVee},
|
| 266 |
+
title = {ToolACE-Hallucination},
|
| 267 |
+
year = {2026},
|
| 268 |
+
publisher = {Hugging Face},
|
| 269 |
+
url = {https://huggingface.co/datasets/jameVee/ToolACE-Hallucination}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
@dataset{toolace,
|
| 273 |
+
author = {Team-ACE},
|
| 274 |
+
title = {ToolACE},
|
| 275 |
+
year = {2024},
|
| 276 |
+
publisher = {Hugging Face},
|
| 277 |
+
url = {https://huggingface.co/datasets/Team-ACE/ToolACE}
|
| 278 |
+
}
|
| 279 |
+
```
|
evaluation_baselines_span_utils.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
ACTION_PATTERNS = {
|
| 9 |
+
"email": [r"\bemail\b", r"\bmail\b", r"\bsend (an )?email\b"],
|
| 10 |
+
"calendar": [r"\bcalendar\b", r"\bschedule\b", r"\bbook\b", r"\bset up (a )?meeting\b"],
|
| 11 |
+
"phone": [r"\bcall\b", r"\bphone\b", r"\bdial\b"],
|
| 12 |
+
"message": [r"\bslack\b", r"\bmessage\b", r"\bping\b", r"\btext\b"],
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def split_context_and_tools(context: str) -> Tuple[str, str]:
|
| 17 |
+
marker = "Available tools: "
|
| 18 |
+
if marker in context:
|
| 19 |
+
tool_output, tools_json = context.split(marker, 1)
|
| 20 |
+
return tool_output.strip(), tools_json.strip()
|
| 21 |
+
return context.strip(), ""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_available_tools(context: str) -> List[Dict]:
|
| 25 |
+
_, tools_json = split_context_and_tools(context)
|
| 26 |
+
if not tools_json:
|
| 27 |
+
return []
|
| 28 |
+
try:
|
| 29 |
+
parsed = json.loads(tools_json)
|
| 30 |
+
if isinstance(parsed, list):
|
| 31 |
+
return parsed
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
return []
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def parse_tool_blocks(context: str) -> List[Tuple[str, object]]:
|
| 38 |
+
left, _ = split_context_and_tools(context)
|
| 39 |
+
s = left.strip()
|
| 40 |
+
blocks: List[Tuple[str, object]] = []
|
| 41 |
+
decoder = json.JSONDecoder()
|
| 42 |
+
i = 0
|
| 43 |
+
n = len(s)
|
| 44 |
+
|
| 45 |
+
while i < n:
|
| 46 |
+
while i < n and s[i] in " \n\t.":
|
| 47 |
+
i += 1
|
| 48 |
+
if i >= n:
|
| 49 |
+
break
|
| 50 |
+
|
| 51 |
+
colon = s.find(":", i)
|
| 52 |
+
if colon == -1:
|
| 53 |
+
break
|
| 54 |
+
|
| 55 |
+
name = s[i:colon].strip()
|
| 56 |
+
j = colon + 1
|
| 57 |
+
while j < n and s[j].isspace():
|
| 58 |
+
j += 1
|
| 59 |
+
if not name or j >= n:
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
payload, end = decoder.raw_decode(s, j)
|
| 64 |
+
blocks.append((name, payload))
|
| 65 |
+
i = end
|
| 66 |
+
continue
|
| 67 |
+
except Exception:
|
| 68 |
+
match = re.search(r'(?:\n|\.\s+)(?=[^\n:]{1,80}:\s*[\[{\"])', s[j:])
|
| 69 |
+
end = j + (match.start() if match else len(s[j:]))
|
| 70 |
+
raw = s[j:end].strip()
|
| 71 |
+
blocks.append((name, raw))
|
| 72 |
+
i = end
|
| 73 |
+
|
| 74 |
+
return blocks
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def humanize_key(key: str) -> str:
|
| 78 |
+
key = str(key).replace("_", " ").replace("-", " ")
|
| 79 |
+
key = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
|
| 80 |
+
key = re.sub(r"\s+", " ", key).strip()
|
| 81 |
+
return key.lower()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def normalize_text(text: str) -> str:
|
| 85 |
+
return re.sub(r"\s+", " ", str(text).lower()).strip()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def content_tokens(text: str) -> List[str]:
|
| 89 |
+
toks = re.findall(r"[A-Za-z0-9_./%:-]+", str(text).lower())
|
| 90 |
+
return [t for t in toks if len(t) >= 3]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def is_large_blob(text: str) -> bool:
|
| 94 |
+
text = str(text)
|
| 95 |
+
if text.startswith("data:image/"):
|
| 96 |
+
return True
|
| 97 |
+
return len(text) > 160 and re.fullmatch(r"[A-Za-z0-9+/=._:-]+", text) is not None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def format_scalar(value: object) -> str:
|
| 101 |
+
if value is None:
|
| 102 |
+
return "null"
|
| 103 |
+
if isinstance(value, bool):
|
| 104 |
+
return "true" if value else "false"
|
| 105 |
+
if isinstance(value, (int, float)):
|
| 106 |
+
return str(value)
|
| 107 |
+
|
| 108 |
+
text = str(value).strip().replace("\n", " ")
|
| 109 |
+
if is_large_blob(text):
|
| 110 |
+
return "[large binary/string omitted]"
|
| 111 |
+
if len(text) > 220:
|
| 112 |
+
return text[:217] + "..."
|
| 113 |
+
return text
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def render_json(value: object, indent: int = 0, max_items: int = 4) -> List[str]:
|
| 117 |
+
sp = " " * indent
|
| 118 |
+
lines: List[str] = []
|
| 119 |
+
|
| 120 |
+
if isinstance(value, dict):
|
| 121 |
+
for k, v in value.items():
|
| 122 |
+
key = humanize_key(k)
|
| 123 |
+
if isinstance(v, (dict, list)):
|
| 124 |
+
lines.append(f"{sp}{key}:")
|
| 125 |
+
lines.extend(render_json(v, indent + 1, max_items=max_items))
|
| 126 |
+
else:
|
| 127 |
+
lines.append(f"{sp}{key}: {format_scalar(v)}")
|
| 128 |
+
return lines
|
| 129 |
+
|
| 130 |
+
if isinstance(value, list):
|
| 131 |
+
for idx, item in enumerate(value[:max_items], 1):
|
| 132 |
+
if isinstance(item, (dict, list)):
|
| 133 |
+
lines.append(f"{sp}- item {idx}:")
|
| 134 |
+
lines.extend(render_json(item, indent + 1, max_items=max_items))
|
| 135 |
+
else:
|
| 136 |
+
lines.append(f"{sp}- item {idx}: {format_scalar(item)}")
|
| 137 |
+
if len(value) > max_items:
|
| 138 |
+
lines.append(f"{sp}- ... {len(value) - max_items} more items")
|
| 139 |
+
return lines
|
| 140 |
+
|
| 141 |
+
return [f"{sp}{format_scalar(value)}"]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def render_available_tools(context: str, max_tools: int = 8) -> List[str]:
|
| 145 |
+
tools = parse_available_tools(context)
|
| 146 |
+
if not tools:
|
| 147 |
+
return []
|
| 148 |
+
|
| 149 |
+
lines = ["Available tools:"]
|
| 150 |
+
for tool in tools[:max_tools]:
|
| 151 |
+
name = tool.get("name", "")
|
| 152 |
+
desc = tool.get("description", "")
|
| 153 |
+
if len(desc) > 180:
|
| 154 |
+
desc = desc[:177] + "..."
|
| 155 |
+
lines.append(f"- {name}: {desc}")
|
| 156 |
+
if len(tools) > max_tools:
|
| 157 |
+
lines.append(f"- ... {len(tools) - max_tools} more tools")
|
| 158 |
+
return lines
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def normalize_tool_context(context: str, max_items: int = 4, max_tools: int = 8) -> str:
|
| 162 |
+
blocks = parse_tool_blocks(context)
|
| 163 |
+
lines: List[str] = []
|
| 164 |
+
for name, payload in blocks:
|
| 165 |
+
lines.append(f"Tool: {name}")
|
| 166 |
+
lines.extend(render_json(payload, indent=1, max_items=max_items))
|
| 167 |
+
lines.extend(render_available_tools(context, max_tools=max_tools))
|
| 168 |
+
return "\n".join(lines)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def add_normalized_context_columns(df):
|
| 172 |
+
df = df.copy()
|
| 173 |
+
df["normalized_context"] = df["context"].apply(normalize_tool_context)
|
| 174 |
+
df["normalized_tool_output"] = df["context"].apply(lambda x: split_context_and_tools(normalize_tool_context(x))[0])
|
| 175 |
+
return df
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def extract_percentages(text: str) -> List[Tuple[int, int, str]]:
|
| 179 |
+
return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r"[+-]?\d+(?:\.\d+)?%", text)]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def extract_dates(text: str) -> List[Tuple[int, int, str]]:
|
| 183 |
+
patterns = [
|
| 184 |
+
r"\b\d{4}-\d{2}-\d{2}\b",
|
| 185 |
+
r"\b\d{4}/\d{2}/\d{2}\b",
|
| 186 |
+
r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},\s+\d{4}\b",
|
| 187 |
+
]
|
| 188 |
+
matches = []
|
| 189 |
+
for pat in patterns:
|
| 190 |
+
matches.extend((m.start(), m.end(), m.group(0)) for m in re.finditer(pat, text))
|
| 191 |
+
return matches
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def extract_numbers(text: str) -> List[Tuple[int, int, str]]:
|
| 195 |
+
return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r"\b\d+(?:\.\d+)?\b", text)]
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def extract_quoted_strings(text: str) -> List[Tuple[int, int, str]]:
|
| 199 |
+
return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r'"[^"\n]{3,}"', text)]
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def extract_capitalized_phrases(text: str) -> List[Tuple[int, int, str]]:
|
| 203 |
+
return [
|
| 204 |
+
(m.start(), m.end(), m.group(0))
|
| 205 |
+
for m in re.finditer(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][A-Za-z0-9.&'-]+){0,4})\b", text)
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def merge_spans(spans: Sequence[Dict]) -> List[Dict]:
|
| 210 |
+
if not spans:
|
| 211 |
+
return []
|
| 212 |
+
spans = sorted(spans, key=lambda x: (x["start"], x["end"]))
|
| 213 |
+
merged = [dict(spans[0])]
|
| 214 |
+
for span in spans[1:]:
|
| 215 |
+
last = merged[-1]
|
| 216 |
+
if span["start"] <= last["end"]:
|
| 217 |
+
last["end"] = max(last["end"], span["end"])
|
| 218 |
+
if len(span.get("text", "")) > len(last.get("text", "")):
|
| 219 |
+
last["text"] = span.get("text", last.get("text", ""))
|
| 220 |
+
last["score"] = max(last.get("score", 0.0), span.get("score", 0.0))
|
| 221 |
+
last["type"] = last.get("type", span.get("type", "hallucination"))
|
| 222 |
+
else:
|
| 223 |
+
merged.append(dict(span))
|
| 224 |
+
return merged
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def normalize_gold_labels(labels: Sequence, text: Optional[str] = None) -> List[Dict]:
|
| 228 |
+
normalized = []
|
| 229 |
+
for label in labels or []:
|
| 230 |
+
if isinstance(label, dict):
|
| 231 |
+
start = int(label.get("start", -1))
|
| 232 |
+
end = int(label.get("end", -1))
|
| 233 |
+
label_type = label.get("type", "hallucination")
|
| 234 |
+
snippet = label.get("text")
|
| 235 |
+
elif isinstance(label, (list, tuple)) and len(label) >= 3:
|
| 236 |
+
start = int(label[0])
|
| 237 |
+
end = int(label[1])
|
| 238 |
+
label_type = label[2]
|
| 239 |
+
snippet = text[start:end] if text and start >= 0 and end > start else None
|
| 240 |
+
else:
|
| 241 |
+
continue
|
| 242 |
+
if start < 0 or end <= start:
|
| 243 |
+
continue
|
| 244 |
+
normalized.append({
|
| 245 |
+
"start": start,
|
| 246 |
+
"end": end,
|
| 247 |
+
"type": label_type,
|
| 248 |
+
"text": snippet if snippet is not None else (text[start:end] if text else ""),
|
| 249 |
+
"score": 1.0,
|
| 250 |
+
})
|
| 251 |
+
return merge_spans(normalized)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def spans_to_char_set(spans: Sequence[Dict]) -> set:
|
| 255 |
+
chars = set()
|
| 256 |
+
for span in spans:
|
| 257 |
+
chars.update(range(int(span["start"]), int(span["end"])))
|
| 258 |
+
return chars
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def evaluate_span_predictions(gold_spans_list: Sequence[Sequence[Dict]], pred_spans_list: Sequence[Sequence[Dict]]) -> Dict[str, float]:
|
| 262 |
+
tp = fp = fn = 0
|
| 263 |
+
exact_matches = 0
|
| 264 |
+
total = len(gold_spans_list)
|
| 265 |
+
|
| 266 |
+
for gold_spans, pred_spans in zip(gold_spans_list, pred_spans_list):
|
| 267 |
+
gold_chars = spans_to_char_set(gold_spans)
|
| 268 |
+
pred_chars = spans_to_char_set(pred_spans)
|
| 269 |
+
tp += len(gold_chars & pred_chars)
|
| 270 |
+
fp += len(pred_chars - gold_chars)
|
| 271 |
+
fn += len(gold_chars - pred_chars)
|
| 272 |
+
|
| 273 |
+
gold_exact = {(s["start"], s["end"], s.get("type", "hallucination")) for s in gold_spans}
|
| 274 |
+
pred_exact = {(s["start"], s["end"], s.get("type", "hallucination")) for s in pred_spans}
|
| 275 |
+
exact_matches += int(gold_exact == pred_exact)
|
| 276 |
+
|
| 277 |
+
precision = tp / (tp + fp) if tp + fp > 0 else 0.0
|
| 278 |
+
recall = tp / (tp + fn) if tp + fn > 0 else 0.0
|
| 279 |
+
f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0
|
| 280 |
+
return {
|
| 281 |
+
"span_precision": precision,
|
| 282 |
+
"span_recall": recall,
|
| 283 |
+
"span_f1": f1,
|
| 284 |
+
"exact_match_rate": exact_matches / total if total else 0.0,
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def build_span_eval_frame(df, pred_col: str) -> Dict[str, float]:
|
| 289 |
+
gold = [normalize_gold_labels(labels, text=output) for labels, output in zip(df["hallucination_labels"], df["output"])]
|
| 290 |
+
pred = df[pred_col].tolist()
|
| 291 |
+
return evaluate_span_predictions(gold, pred)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def lexical_hallucination_spans(row) -> List[Dict]:
|
| 295 |
+
output = row["output"]
|
| 296 |
+
tool_output = row.get("normalized_tool_output", row["context"])
|
| 297 |
+
available_tools = parse_available_tools(row["context"])
|
| 298 |
+
tool_norm = normalize_text(tool_output)
|
| 299 |
+
spans = []
|
| 300 |
+
|
| 301 |
+
candidate_extractors = [
|
| 302 |
+
("percentage", extract_percentages),
|
| 303 |
+
("date", extract_dates),
|
| 304 |
+
("number", extract_numbers),
|
| 305 |
+
("quote", extract_quoted_strings),
|
| 306 |
+
("entity", extract_capitalized_phrases),
|
| 307 |
+
]
|
| 308 |
+
|
| 309 |
+
for kind, extractor in candidate_extractors:
|
| 310 |
+
for start, end, value in extractor(output):
|
| 311 |
+
value_norm = normalize_text(value.strip('"'))
|
| 312 |
+
if len(value_norm) < 2:
|
| 313 |
+
continue
|
| 314 |
+
if value_norm not in tool_norm:
|
| 315 |
+
spans.append({
|
| 316 |
+
"start": start,
|
| 317 |
+
"end": end,
|
| 318 |
+
"text": output[start:end],
|
| 319 |
+
"type": "hallucination",
|
| 320 |
+
"score": 0.6 if kind in {"number", "entity"} else 0.8,
|
| 321 |
+
})
|
| 322 |
+
|
| 323 |
+
tool_blob = normalize_text(" ".join(
|
| 324 |
+
f"{tool.get('name', '')} {tool.get('description', '')}" for tool in available_tools
|
| 325 |
+
))
|
| 326 |
+
for affordance, patterns in ACTION_PATTERNS.items():
|
| 327 |
+
supported = affordance in tool_blob
|
| 328 |
+
for pattern in patterns:
|
| 329 |
+
for match in re.finditer(pattern, output, flags=re.IGNORECASE):
|
| 330 |
+
if not supported:
|
| 331 |
+
spans.append({
|
| 332 |
+
"start": match.start(),
|
| 333 |
+
"end": match.end(),
|
| 334 |
+
"text": match.group(0),
|
| 335 |
+
"type": "missing_tool",
|
| 336 |
+
"score": 0.95,
|
| 337 |
+
})
|
| 338 |
+
|
| 339 |
+
return merge_spans(spans)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def longest_streak(flags: Sequence[bool]) -> int:
|
| 343 |
+
best = 0
|
| 344 |
+
cur = 0
|
| 345 |
+
for flag in flags:
|
| 346 |
+
cur = cur + 1 if flag else 0
|
| 347 |
+
best = max(best, cur)
|
| 348 |
+
return best
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def aggregate_span_features(spans: Sequence[Dict], text_length: int) -> Dict[str, float]:
|
| 352 |
+
lengths = [max(0, span["end"] - span["start"]) for span in spans]
|
| 353 |
+
scores = [float(span.get("score", 0.0)) for span in spans]
|
| 354 |
+
coverage = sum(lengths) / max(1, text_length)
|
| 355 |
+
return {
|
| 356 |
+
"num_spans": len(spans),
|
| 357 |
+
"max_span_score": max(scores) if scores else 0.0,
|
| 358 |
+
"mean_span_score": float(np.mean(scores)) if scores else 0.0,
|
| 359 |
+
"span_char_fraction": coverage,
|
| 360 |
+
"max_span_len": max(lengths) if lengths else 0.0,
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def aggregate_lookback_features(ratios: Sequence[Dict]) -> Dict[str, float]:
|
| 365 |
+
if not ratios:
|
| 366 |
+
return {
|
| 367 |
+
"mean_ratio": 0.5,
|
| 368 |
+
"min_ratio": 0.5,
|
| 369 |
+
"frac_low_03": 0.0,
|
| 370 |
+
"frac_low_02": 0.0,
|
| 371 |
+
"std_ratio": 0.0,
|
| 372 |
+
"bottom3_mean": 0.5,
|
| 373 |
+
"longest_low_streak": 0.0,
|
| 374 |
+
"mean_ratio_numeric": 0.5,
|
| 375 |
+
"frac_low_numeric": 0.0,
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
vals = np.array([r["lookback_ratio"] for r in ratios], dtype=float)
|
| 379 |
+
bottom3 = np.sort(vals)[: min(3, len(vals))]
|
| 380 |
+
numeric_mask = np.array([bool(re.search(r"\d", r["token"])) for r in ratios])
|
| 381 |
+
|
| 382 |
+
if numeric_mask.any():
|
| 383 |
+
numeric_vals = vals[numeric_mask]
|
| 384 |
+
mean_ratio_numeric = float(numeric_vals.mean())
|
| 385 |
+
frac_low_numeric = float((numeric_vals < 0.3).mean())
|
| 386 |
+
else:
|
| 387 |
+
mean_ratio_numeric = 0.5
|
| 388 |
+
frac_low_numeric = 0.0
|
| 389 |
+
|
| 390 |
+
low_flags = vals < 0.3
|
| 391 |
+
return {
|
| 392 |
+
"mean_ratio": float(vals.mean()),
|
| 393 |
+
"min_ratio": float(vals.min()),
|
| 394 |
+
"frac_low_03": float((vals < 0.3).mean()),
|
| 395 |
+
"frac_low_02": float((vals < 0.2).mean()),
|
| 396 |
+
"std_ratio": float(vals.std()),
|
| 397 |
+
"bottom3_mean": float(bottom3.mean()),
|
| 398 |
+
"longest_low_streak": float(longest_streak(low_flags.tolist())),
|
| 399 |
+
"mean_ratio_numeric": mean_ratio_numeric,
|
| 400 |
+
"frac_low_numeric": frac_low_numeric,
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def spans_from_lookback_ratios(answer: str, ratios: Sequence[Dict], low_threshold: float = 0.22, min_chars: int = 3) -> List[Dict]:
|
| 405 |
+
spans = []
|
| 406 |
+
cur = None
|
| 407 |
+
|
| 408 |
+
for ratio in ratios:
|
| 409 |
+
start = ratio.get("start")
|
| 410 |
+
end = ratio.get("end")
|
| 411 |
+
if start is None or end is None or end <= start:
|
| 412 |
+
continue
|
| 413 |
+
|
| 414 |
+
text = answer[start:end]
|
| 415 |
+
is_content = bool(re.search(r"[A-Za-z0-9]", text))
|
| 416 |
+
low = ratio["lookback_ratio"] < low_threshold
|
| 417 |
+
|
| 418 |
+
if low and is_content:
|
| 419 |
+
if cur is None:
|
| 420 |
+
cur = {
|
| 421 |
+
"start": start,
|
| 422 |
+
"end": end,
|
| 423 |
+
"score_values": [1.0 - float(ratio["lookback_ratio"])],
|
| 424 |
+
}
|
| 425 |
+
else:
|
| 426 |
+
if start <= cur["end"] + 1:
|
| 427 |
+
cur["end"] = end
|
| 428 |
+
cur["score_values"].append(1.0 - float(ratio["lookback_ratio"]))
|
| 429 |
+
else:
|
| 430 |
+
spans.append(cur)
|
| 431 |
+
cur = {
|
| 432 |
+
"start": start,
|
| 433 |
+
"end": end,
|
| 434 |
+
"score_values": [1.0 - float(ratio["lookback_ratio"])],
|
| 435 |
+
}
|
| 436 |
+
elif cur is not None:
|
| 437 |
+
spans.append(cur)
|
| 438 |
+
cur = None
|
| 439 |
+
|
| 440 |
+
if cur is not None:
|
| 441 |
+
spans.append(cur)
|
| 442 |
+
|
| 443 |
+
normalized = []
|
| 444 |
+
for span in spans:
|
| 445 |
+
if span["end"] - span["start"] < min_chars:
|
| 446 |
+
continue
|
| 447 |
+
normalized.append({
|
| 448 |
+
"start": span["start"],
|
| 449 |
+
"end": span["end"],
|
| 450 |
+
"text": answer[span["start"]:span["end"]],
|
| 451 |
+
"type": "hallucination",
|
| 452 |
+
"score": float(np.mean(span["score_values"])) if span["score_values"] else 0.0,
|
| 453 |
+
})
|
| 454 |
+
return merge_spans(normalized)
|
lettuce_clf.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6277c21bdc49573d0408b83728d9f4e32b1597d58717f63a88d6c8d97b5c2dc3
|
| 3 |
+
size 1809
|
lexical_clf.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:21aab7c480d2eb013c14842564366953341c74f18cab59a2fc574dc8264829ed
|
| 3 |
+
size 1809
|
lookback_clf.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9ad283f9dbb6cacc9a453a61e64db751ba43153ff9dcfa4b7ad28bd74185f6b9
|
| 3 |
+
size 2241
|
model_meta.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lexical_features": [
|
| 3 |
+
"num_spans",
|
| 4 |
+
"max_span_score",
|
| 5 |
+
"mean_span_score",
|
| 6 |
+
"span_char_fraction",
|
| 7 |
+
"max_span_len"
|
| 8 |
+
],
|
| 9 |
+
"lettuce_features": [
|
| 10 |
+
"num_spans",
|
| 11 |
+
"max_span_score",
|
| 12 |
+
"mean_span_score",
|
| 13 |
+
"span_char_fraction",
|
| 14 |
+
"max_span_len"
|
| 15 |
+
],
|
| 16 |
+
"lookback_features": [
|
| 17 |
+
"mean_ratio",
|
| 18 |
+
"min_ratio",
|
| 19 |
+
"frac_low_03",
|
| 20 |
+
"frac_low_02",
|
| 21 |
+
"std_ratio",
|
| 22 |
+
"bottom3_mean",
|
| 23 |
+
"longest_low_streak",
|
| 24 |
+
"mean_ratio_numeric",
|
| 25 |
+
"frac_low_numeric",
|
| 26 |
+
"num_spans",
|
| 27 |
+
"max_span_score",
|
| 28 |
+
"mean_span_score",
|
| 29 |
+
"span_char_fraction",
|
| 30 |
+
"max_span_len"
|
| 31 |
+
],
|
| 32 |
+
"seed": 1241,
|
| 33 |
+
"split": {
|
| 34 |
+
"train": 3308,
|
| 35 |
+
"test": 828
|
| 36 |
+
},
|
| 37 |
+
"backbone_lettuce": "KRLabsOrg/lettucedect-base-modernbert-en-v1",
|
| 38 |
+
"backbone_lookback": "Qwen/Qwen2.5-0.5B",
|
| 39 |
+
"source_dataset": "jameVee/ToolACE-Hallucination",
|
| 40 |
+
"hallucination_types": [
|
| 41 |
+
"missing_tool",
|
| 42 |
+
"overgeneration",
|
| 43 |
+
"tool_output_contradiction"
|
| 44 |
+
],
|
| 45 |
+
"metrics_test": {
|
| 46 |
+
"lexical_span_verifier": {
|
| 47 |
+
"accuracy": 0.5664251207729468,
|
| 48 |
+
"f1": 0.5232403718459495,
|
| 49 |
+
"auroc": 0.6025060625811036,
|
| 50 |
+
"span_f1": 0.052821437773769644
|
| 51 |
+
},
|
| 52 |
+
"lettuce_span_supervised": {
|
| 53 |
+
"accuracy": 0.677536231884058,
|
| 54 |
+
"f1": 0.6317241379310344,
|
| 55 |
+
"auroc": 0.7214236643238388,
|
| 56 |
+
"span_f1": 0.20837031474021983
|
| 57 |
+
},
|
| 58 |
+
"lookback_span_supervised": {
|
| 59 |
+
"accuracy": 0.4830917874396135,
|
| 60 |
+
"f1": 0.5532359081419624,
|
| 61 |
+
"auroc": 0.5116260635205834,
|
| 62 |
+
"span_f1": 0.0
|
| 63 |
+
},
|
| 64 |
+
"soft_vote_ensemble": {
|
| 65 |
+
"accuracy": 0.6763285024154589,
|
| 66 |
+
"f1": 0.6598984771573604,
|
| 67 |
+
"auroc": 0.7214089849507066,
|
| 68 |
+
"span_f1": 0.17918399775669488
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|