Convert static replay to Streamlit app (hosted profile)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +5 -0
- .streamlit/config.toml +20 -0
- README.md +14 -7
- index.html +0 -144
- pyproject.toml +21 -0
- requirements.txt +15 -0
- sirin/__init__.py +0 -0
- sirin/classification/__init__.py +1 -0
- sirin/classification/linear.py +335 -0
- sirin/configs/detector/probing.yaml +8 -0
- sirin/configs/feature_processor/hiddens.yaml +7 -0
- sirin/configs/model_adapter/default.yaml +8 -0
- sirin/configs/model_adapter/hf_large_model.yaml +17 -0
- sirin/configs/model_adapter/hf_with_batching.yaml +18 -0
- sirin/configs/model_adapter/openai_with_parallel.yaml +16 -0
- sirin/configs/model_adapter/openrouter.yaml +15 -0
- sirin/configs/model_adapter/vllm_with_batching.yaml +18 -0
- sirin/configs/pipeline/probing.yaml +23 -0
- sirin/configs/token_locator_config/default.yaml +1 -0
- sirin/configs/train.yaml +24 -0
- sirin/configs/train_args/default.yaml +10 -0
- sirin/definitions/__init__.py +3 -0
- sirin/definitions/constants.py +20 -0
- sirin/definitions/enums.py +204 -0
- sirin/definitions/types.py +35 -0
- sirin/detection/__init__.py +0 -0
- sirin/detection/_lm_polygraph_compat.py +168 -0
- sirin/detection/approximators/__init__.py +18 -0
- sirin/detection/approximators/base.py +13 -0
- sirin/detection/approximators/sep.py +224 -0
- sirin/detection/base.py +497 -0
- sirin/detection/judging/__init__.py +40 -0
- sirin/detection/judging/judges/__init__.py +4 -0
- sirin/detection/judging/judges/base.py +292 -0
- sirin/detection/judging/judges/claim/__init__.py +3 -0
- sirin/detection/judging/judges/claim/decoder.py +201 -0
- sirin/detection/judging/judges/claim/encoder.py +169 -0
- sirin/detection/judging/judges/claim/openai.py +134 -0
- sirin/detection/judging/judges/sequence/__init__.py +3 -0
- sirin/detection/judging/judges/sequence/decoder.py +139 -0
- sirin/detection/judging/judges/sequence/encoder.py +108 -0
- sirin/detection/judging/judges/sequence/openai.py +99 -0
- sirin/detection/judging/judges/token/__init__.py +3 -0
- sirin/detection/judging/judges/token/decoder.py +186 -0
- sirin/detection/judging/judges/token/encoder.py +131 -0
- sirin/detection/judging/judges/token/openai.py +139 -0
- sirin/detection/judging/judges/utils/__init__.py +23 -0
- sirin/detection/judging/judges/utils/decoder.py +67 -0
- sirin/detection/judging/judges/utils/metrics.py +106 -0
- sirin/detection/judging/judges/utils/prompts.py +127 -0
.gitattributes
CHANGED
|
@@ -48,3 +48,8 @@ static/videos/teaser.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
| 48 |
static/videos/toby.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 49 |
assets/logo.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
assets/silk_bg.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
static/videos/toby.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 49 |
assets/logo.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
assets/silk_bg.jpg filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
sirin/ui/assets/logo.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
sirin/ui/assets/silk_bg.jpg filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
sirin/ui/static/silk_bg.jpg filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
sirin/ui/workspace/frontend/build/NotoColorEmoji-nature-Rpfd13Si.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
sirin/ui/workspace/frontend/build/NotoColorEmoji-objects-EKxheXEn.woff2 filter=lfs diff=lfs merge=lfs -text
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Native-widget palette + fonts mirror sirin/ui/tokens.json (palette.light + fonts). The @font-face
|
| 2 |
+
# rules that back these families are injected by sirin/ui/styles.py from app/static/fonts/*.
|
| 3 |
+
[theme]
|
| 4 |
+
base = "light"
|
| 5 |
+
primaryColor = "#E562A8"
|
| 6 |
+
backgroundColor = "#FAF3F8"
|
| 7 |
+
secondaryBackgroundColor = "#FFFFFF"
|
| 8 |
+
textColor = "#231A21"
|
| 9 |
+
linkColor = "#0E9384"
|
| 10 |
+
baseRadius = "12px"
|
| 11 |
+
font = "Manrope"
|
| 12 |
+
codeFont = "IBM Plex Mono"
|
| 13 |
+
|
| 14 |
+
[browser]
|
| 15 |
+
gatherUsageStats = false
|
| 16 |
+
|
| 17 |
+
[server]
|
| 18 |
+
# Serve sirin/ui/static/* at /app/static/* so the silk backdrop loads from a cached static file
|
| 19 |
+
# instead of a ~259KB base64 data URI re-shipped in the injected CSS on every rerun.
|
| 20 |
+
enableStaticServing = true
|
README.md
CHANGED
|
@@ -1,21 +1,28 @@
|
|
| 1 |
---
|
| 2 |
-
title: SIRIN
|
| 3 |
emoji: 🔎
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: pink
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
tags:
|
| 8 |
- hallucination-detection
|
| 9 |
- answerability
|
| 10 |
- longmemeval
|
| 11 |
- demo
|
| 12 |
-
short_description:
|
| 13 |
---
|
| 14 |
|
| 15 |
-
# SIRIN
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
-
|
|
|
|
| 1 |
---
|
| 2 |
+
title: SIRIN
|
| 3 |
emoji: 🔎
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: pink
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: "1.59.1"
|
| 8 |
+
python_version: "3.11"
|
| 9 |
+
app_file: sirin/ui/streamlit_app.py
|
| 10 |
+
pinned: false
|
| 11 |
tags:
|
| 12 |
- hallucination-detection
|
| 13 |
- answerability
|
| 14 |
- longmemeval
|
| 15 |
- demo
|
| 16 |
+
short_description: Hallucination inspection — replay + live API judges
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# SIRIN
|
| 20 |
|
| 21 |
+
Interactive workspace for contextual-inconsistency inspection: replay curated
|
| 22 |
+
LongMemEval / PsiloQA cases with recorded evidence and scores, or run live
|
| 23 |
+
LLM-judge detectors against API providers.
|
| 24 |
|
| 25 |
+
External API judge calls are opt-in per session and use the Space's own key;
|
| 26 |
+
no user data is stored. Local model inference is disabled in this hosted build.
|
| 27 |
|
| 28 |
+
Full project: https://github.com/sb-ai-lab/SIRIN
|
index.html
DELETED
|
@@ -1,144 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<meta name="description" content="Offline replay of verified SIRIN inspection artifacts.">
|
| 7 |
-
<title>SIRIN UI Demo</title>
|
| 8 |
-
<style>
|
| 9 |
-
:root {
|
| 10 |
-
--bg: #10091c;
|
| 11 |
-
--panel: rgba(26, 16, 44, .82);
|
| 12 |
-
--line: rgba(255, 255, 255, .14);
|
| 13 |
-
--text: #f6f1fc;
|
| 14 |
-
--muted: #c9bedb;
|
| 15 |
-
--mint: #a9f0d6;
|
| 16 |
-
--pink: #f07bbb;
|
| 17 |
-
--violet: #af8dff;
|
| 18 |
-
--danger: #ff637f;
|
| 19 |
-
color-scheme: dark;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
* { box-sizing: border-box; }
|
| 23 |
-
body {
|
| 24 |
-
min-height: 100vh;
|
| 25 |
-
margin: 0;
|
| 26 |
-
color: var(--text);
|
| 27 |
-
background: linear-gradient(rgba(16, 9, 28, .88), rgba(16, 9, 28, .93)),
|
| 28 |
-
url("assets/silk_bg.jpg") center / cover fixed, var(--bg);
|
| 29 |
-
font: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
| 30 |
-
"Segoe UI", sans-serif;
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0 64px; }
|
| 34 |
-
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
|
| 35 |
-
.logo { width: 58px; height: 58px; object-fit: contain; }
|
| 36 |
-
h1, h2, h3, p { margin: 0; }
|
| 37 |
-
h1 { font-size: clamp(2rem, 5vw, 3.2rem); letter-spacing: -.05em; }
|
| 38 |
-
h2 { font-size: 1.15rem; margin-bottom: 12px; }
|
| 39 |
-
.lede, .muted { color: var(--muted); }
|
| 40 |
-
.notice { margin: 26px 0 18px; padding: 14px 16px; border-left: 3px solid var(--mint); color: var(--muted); background: rgba(169, 240, 214, .08); }
|
| 41 |
-
.tabs { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0 24px; }
|
| 42 |
-
button {
|
| 43 |
-
border: 1px solid var(--line); border-radius: 999px; padding: 9px 15px; cursor: pointer;
|
| 44 |
-
color: var(--text); background: rgba(255, 255, 255, .06); font: inherit; transition: .18s ease;
|
| 45 |
-
}
|
| 46 |
-
button:hover, button[aria-selected="true"] { border-color: var(--pink); background: rgba(240, 123, 187, .18); box-shadow: 0 0 0 3px rgba(240, 123, 187, .1); }
|
| 47 |
-
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(280px, .9fr); gap: 18px; }
|
| 48 |
-
.panel { border: 1px solid var(--line); border-radius: 18px; padding: clamp(18px, 3vw, 28px); background: var(--panel); backdrop-filter: blur(14px); box-shadow: 0 16px 48px rgba(0, 0, 0, .2); }
|
| 49 |
-
.case-head { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
|
| 50 |
-
.question { margin-top: 4px; color: var(--muted); font-size: 1.04rem; }
|
| 51 |
-
.pill { flex: none; border-radius: 999px; padding: 5px 10px; color: #190b20; background: var(--mint); font-size: .8rem; font-weight: 750; }
|
| 52 |
-
.response { margin-top: 22px; padding: 18px; border-radius: 14px; border: 1px solid rgba(240, 123, 187, .32); background: rgba(240, 123, 187, .08); }
|
| 53 |
-
.eyebrow { display: block; margin-bottom: 7px; color: var(--pink); font-size: .73rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
| 54 |
-
.answer { font-size: 1.24rem; font-weight: 750; line-height: 1.9; }
|
| 55 |
-
.heat { border-bottom: 3px solid var(--pink); border-radius: 4px; padding: .05em .15em; }
|
| 56 |
-
.gold { margin-top: 11px; color: var(--muted); font-size: .9rem; }
|
| 57 |
-
.gold strong { color: var(--mint); }
|
| 58 |
-
.section { margin-top: 22px; }
|
| 59 |
-
.evidence { display: grid; gap: 10px; }
|
| 60 |
-
.evidence article { padding: 13px 14px; border-left: 3px solid var(--violet); border-radius: 0 10px 10px 0; background: rgba(175, 141, 255, .09); }
|
| 61 |
-
.evidence h3 { color: var(--mint); font-size: .85rem; }
|
| 62 |
-
.evidence p { margin-top: 4px; color: var(--muted); font-size: .94rem; }
|
| 63 |
-
.metric { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
|
| 64 |
-
.metric div { padding: 12px; border-radius: 10px; background: rgba(255, 255, 255, .055); }
|
| 65 |
-
.metric span { display: block; color: var(--muted); font-size: .76rem; text-transform: uppercase; letter-spacing: .06em; }
|
| 66 |
-
.metric strong { display: block; margin-top: 2px; font-size: .9rem; }
|
| 67 |
-
details { margin-top: 18px; border-top: 1px solid var(--line); padding-top: 14px; }
|
| 68 |
-
summary { cursor: pointer; color: var(--mint); font-weight: 700; }
|
| 69 |
-
pre { overflow: auto; margin: 12px 0 0; padding: 12px; border-radius: 10px; background: rgba(0, 0, 0, .28); color: var(--muted); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; }
|
| 70 |
-
footer { margin-top: 26px; color: var(--muted); font-size: .85rem; }
|
| 71 |
-
a { color: var(--mint); }
|
| 72 |
-
.error { color: #ffd5dd; border-left-color: var(--danger); }
|
| 73 |
-
@media (max-width: 760px) { .shell { width: min(100% - 24px, 1160px); padding-top: 24px; } .grid { grid-template-columns: 1fr; } .case-head { display: block; } .pill { display: inline-block; margin-top: 10px; } }
|
| 74 |
-
</style>
|
| 75 |
-
</head>
|
| 76 |
-
<body>
|
| 77 |
-
<main class="shell">
|
| 78 |
-
<header class="hero">
|
| 79 |
-
<img class="logo" src="assets/logo.png" alt="SIRIN logo">
|
| 80 |
-
<div>
|
| 81 |
-
<h1>SIRIN</h1>
|
| 82 |
-
<p class="lede">Semantic Inconsistency Recognition & Inspection Nexus</p>
|
| 83 |
-
</div>
|
| 84 |
-
</header>
|
| 85 |
-
<p class="notice">Offline replay of verified LongMemEval artifacts. This public Space does not load model weights, run detectors, make API calls, or retain user data.</p>
|
| 86 |
-
<nav id="tabs" class="tabs" aria-label="Recorded cases"></nav>
|
| 87 |
-
<section id="app" aria-live="polite"><p class="muted">Loading verified artifacts…</p></section>
|
| 88 |
-
<footer>For the full live workflow, visit <a href="https://github.com/sb-ai-lab/SIRIN">the SIRIN repository</a>.</footer>
|
| 89 |
-
</main>
|
| 90 |
-
<script>
|
| 91 |
-
const tabs = document.querySelector('#tabs');
|
| 92 |
-
const app = document.querySelector('#app');
|
| 93 |
-
const escapeHtml = value => String(value ?? '').replace(/[&<>'"]/g, character => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[character]));
|
| 94 |
-
const scoreColor = score => `hsl(${Math.round(148 - Math.max(0, Math.min(1, score)) * 148)} 86% 66%)`;
|
| 95 |
-
|
| 96 |
-
function renderedAnswer(caseData) {
|
| 97 |
-
const trace = caseData.token_trace;
|
| 98 |
-
if (trace?.token_pieces?.length) {
|
| 99 |
-
return trace.token_pieces.map((piece, index) => {
|
| 100 |
-
const score = trace.normalized_scores[index] ?? 0;
|
| 101 |
-
return `<span class="heat" title="relative token uncertainty: ${score.toFixed(3)}" style="border-bottom-color:${scoreColor(score)};background:${scoreColor(score)}22">${escapeHtml(piece)}</span>`;
|
| 102 |
-
}).join('');
|
| 103 |
-
}
|
| 104 |
-
const score = Number(caseData.score ?? 0);
|
| 105 |
-
return `<span class="heat" title="recorded sequence score: ${score.toFixed(3)}" style="border-bottom-color:${scoreColor(score)};background:${scoreColor(score)}22">${escapeHtml(caseData.prediction)}</span>`;
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
function render(caseData) {
|
| 109 |
-
const provenance = caseData.provenance || {};
|
| 110 |
-
const metric = caseData.token_trace
|
| 111 |
-
? 'Token uncertainty · localization only'
|
| 112 |
-
: `Recorded sequence score · ${Number(caseData.score).toFixed(3)}`;
|
| 113 |
-
app.innerHTML = `
|
| 114 |
-
<div class="grid">
|
| 115 |
-
<article class="panel">
|
| 116 |
-
<div class="case-head"><div><h2>${escapeHtml(caseData.title)}</h2><p class="question">${escapeHtml(caseData.question)}</p></div><span class="pill">${metric}</span></div>
|
| 117 |
-
<section class="response"><span class="eyebrow">Recorded response</span><div class="answer">${renderedAnswer(caseData)}</div><p class="gold">Dataset reference: <strong>${escapeHtml(caseData.gold)}</strong></p></section>
|
| 118 |
-
<section class="section"><span class="eyebrow">Decisive evidence</span><div class="evidence">${(caseData.evidence_excerpts || []).map(evidence => `<article><h3>${escapeHtml(evidence.cue || evidence.label)} · rank ${escapeHtml(evidence.rank)}${evidence.date ? ` · ${escapeHtml(evidence.date)}` : ''}</h3><p>${escapeHtml(evidence.content)}</p></article>`).join('')}</div></section>
|
| 119 |
-
</article>
|
| 120 |
-
<aside class="panel">
|
| 121 |
-
<span class="eyebrow">Recorded scorer</span><h2>${escapeHtml(caseData.detector)}</h2>
|
| 122 |
-
<div class="metric"><div><span>Dataset</span><strong>${escapeHtml(provenance.dataset || '—')}-${escapeHtml(provenance.dataset_variant || '—')}</strong></div><div><span>Model</span><strong>${escapeHtml(provenance.model || '—')}</strong></div><div><span>Protocol</span><strong>${escapeHtml(provenance.score_protocol || '—')}</strong></div><div><span>Generation temp.</span><strong>${escapeHtml(provenance.generation_temperature ?? '—')}</strong></div></div>
|
| 123 |
-
<details><summary>Artifact provenance</summary><pre>${escapeHtml(JSON.stringify({ sample_id: caseData.sample_id, prompt_sha256: caseData.prompt_sha256, messages_sha256: caseData.messages_sha256, detector: caseData.detector, score: caseData.score, provenance }, null, 2))}</pre></details>
|
| 124 |
-
</aside>
|
| 125 |
-
</div>`;
|
| 126 |
-
[...tabs.children].forEach(tab => tab.setAttribute('aria-selected', String(tab.dataset.id === caseData.sample_id)));
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
fetch('assets/demo_cases.json')
|
| 130 |
-
.then(response => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
|
| 131 |
-
.then(payload => {
|
| 132 |
-
const cases = payload.cases || [];
|
| 133 |
-
if (!cases.length) throw new Error('No recorded cases were found.');
|
| 134 |
-
cases.forEach((caseData, index) => {
|
| 135 |
-
const tab = document.createElement('button');
|
| 136 |
-
tab.type = 'button'; tab.dataset.id = caseData.sample_id; tab.textContent = caseData.title;
|
| 137 |
-
tab.setAttribute('aria-selected', String(index === 0)); tab.addEventListener('click', () => render(caseData)); tabs.append(tab);
|
| 138 |
-
});
|
| 139 |
-
render(cases[0]);
|
| 140 |
-
})
|
| 141 |
-
.catch(error => { app.innerHTML = `<p class="notice error">Unable to load the verified demo artifacts: ${escapeHtml(error.message)}</p>`; });
|
| 142 |
-
</script>
|
| 143 |
-
</body>
|
| 144 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pyproject.toml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=45", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "sirin"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "SIRIN UI - hosted Space build (deps pinned in requirements.txt)"
|
| 9 |
+
requires-python = ">=3.11,<3.14"
|
| 10 |
+
dependencies = []
|
| 11 |
+
|
| 12 |
+
[[tool.streamlit.component.components]]
|
| 13 |
+
name = "sirin_workspace"
|
| 14 |
+
asset_dir = "ui/workspace/frontend/build"
|
| 15 |
+
|
| 16 |
+
[tool.setuptools.packages.find]
|
| 17 |
+
include = ["sirin*"]
|
| 18 |
+
|
| 19 |
+
[tool.setuptools.package-data]
|
| 20 |
+
"sirin.ui" = ["assets/*", "static/*", "static/fonts/*", "tokens.json"]
|
| 21 |
+
"sirin.ui.workspace" = ["pyproject.toml", "frontend/package.json", "frontend/build/*"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
torch==2.9.0+cpu
|
| 3 |
+
streamlit==1.59.1
|
| 4 |
+
transformers==4.57.6
|
| 5 |
+
datasets==3.6.0
|
| 6 |
+
peft==0.17.1
|
| 7 |
+
numpy==1.26.4
|
| 8 |
+
scipy==1.17.1
|
| 9 |
+
scikit-learn==1.6.1
|
| 10 |
+
hydra-core==1.3.2
|
| 11 |
+
joblib==1.5.3
|
| 12 |
+
loguru==0.7.3
|
| 13 |
+
pydantic==2.13.0
|
| 14 |
+
httpx==0.28.1
|
| 15 |
+
-e .
|
sirin/__init__.py
ADDED
|
File without changes
|
sirin/classification/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from .linear import LinearClassifier
|
sirin/classification/linear.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Tuple
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Identity(nn.Module):
|
| 8 |
+
def __init__(self):
|
| 9 |
+
super(Identity, self).__init__()
|
| 10 |
+
|
| 11 |
+
def forward(self, x):
|
| 12 |
+
return x
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class LinearClassifier(nn.Module):
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
embedding_dim: List[int],
|
| 19 |
+
num_features: List[int],
|
| 20 |
+
attention_pooling: List[bool],
|
| 21 |
+
ensemble: List[int],
|
| 22 |
+
num_classes: int = 1,
|
| 23 |
+
use_contrastive: bool = True,
|
| 24 |
+
projection_dim: int = 128,
|
| 25 |
+
contrastive_layers: Optional[List[int]] = None,
|
| 26 |
+
projection_hidden_dim: Optional[int] = None,
|
| 27 |
+
projection_num_layers: int = 2,
|
| 28 |
+
use_projection_dropout: bool = False,
|
| 29 |
+
projection_dropout: float = 0.1,
|
| 30 |
+
):
|
| 31 |
+
super().__init__()
|
| 32 |
+
|
| 33 |
+
self.use_contrastive = use_contrastive
|
| 34 |
+
self.projection_dim = projection_dim
|
| 35 |
+
self.projection_hidden_dim = projection_hidden_dim
|
| 36 |
+
self.projection_num_layers = projection_num_layers
|
| 37 |
+
self.use_projection_dropout = use_projection_dropout
|
| 38 |
+
self.projection_dropout = projection_dropout
|
| 39 |
+
|
| 40 |
+
self.contrastive_layers = contrastive_layers or range(len(embedding_dim))
|
| 41 |
+
|
| 42 |
+
self.q = nn.ModuleList([])
|
| 43 |
+
self.attention_pooling = attention_pooling
|
| 44 |
+
|
| 45 |
+
self.layer_classifiers = nn.ModuleList([Identity() for _ in embedding_dim])
|
| 46 |
+
|
| 47 |
+
for i in ensemble:
|
| 48 |
+
self.layer_classifiers[i] = nn.Linear(
|
| 49 |
+
embedding_dim[i] * num_features[i], num_classes
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
non_ensemble_size = sum([
|
| 53 |
+
embedding_dim[i] * num_features[i]
|
| 54 |
+
for i in range(len(num_features))
|
| 55 |
+
if i not in ensemble
|
| 56 |
+
])
|
| 57 |
+
ensemble_size = len(ensemble)
|
| 58 |
+
self.final_vector_size = non_ensemble_size + ensemble_size
|
| 59 |
+
|
| 60 |
+
self.classifier = nn.Linear(self.final_vector_size, num_classes)
|
| 61 |
+
|
| 62 |
+
if self.use_contrastive:
|
| 63 |
+
self.projection_head = self._build_projection_head()
|
| 64 |
+
|
| 65 |
+
for e_d, n_f in zip(embedding_dim, num_features):
|
| 66 |
+
queries = nn.ParameterList()
|
| 67 |
+
for i in range(n_f):
|
| 68 |
+
query = nn.Parameter(torch.empty(1, 1, e_d))
|
| 69 |
+
nn.init.xavier_uniform_(query)
|
| 70 |
+
queries.append(query)
|
| 71 |
+
self.q.append(queries)
|
| 72 |
+
|
| 73 |
+
def _build_projection_head(self) -> nn.Module:
|
| 74 |
+
"""Builds multi-layer projection head for contrastive learning."""
|
| 75 |
+
projection_layers = []
|
| 76 |
+
|
| 77 |
+
projection_input_dim = self.final_vector_size
|
| 78 |
+
hidden_dim = self.projection_hidden_dim or (projection_input_dim // 2)
|
| 79 |
+
|
| 80 |
+
# First layer
|
| 81 |
+
projection_layers.append(nn.Linear(projection_input_dim, hidden_dim))
|
| 82 |
+
projection_layers.append(nn.BatchNorm1d(hidden_dim))
|
| 83 |
+
projection_layers.append(nn.ReLU(inplace=True))
|
| 84 |
+
|
| 85 |
+
if self.use_projection_dropout:
|
| 86 |
+
projection_layers.append(nn.Dropout(self.projection_dropout))
|
| 87 |
+
|
| 88 |
+
# Middle layers (if num_layers > 2)
|
| 89 |
+
for _ in range(self.projection_num_layers - 2):
|
| 90 |
+
projection_layers.append(nn.Linear(hidden_dim, hidden_dim))
|
| 91 |
+
projection_layers.append(nn.BatchNorm1d(hidden_dim))
|
| 92 |
+
projection_layers.append(nn.ReLU(inplace=True))
|
| 93 |
+
|
| 94 |
+
if self.use_projection_dropout:
|
| 95 |
+
projection_layers.append(nn.Dropout(self.projection_dropout))
|
| 96 |
+
|
| 97 |
+
# Final projection layer
|
| 98 |
+
projection_layers.append(nn.Linear(hidden_dim, self.projection_dim))
|
| 99 |
+
|
| 100 |
+
return nn.Sequential(*projection_layers)
|
| 101 |
+
|
| 102 |
+
def _extract_contrastive_features(
|
| 103 |
+
self,
|
| 104 |
+
hiddens_list: List[torch.Tensor],
|
| 105 |
+
attention_masks_list: List[torch.Tensor] = None,
|
| 106 |
+
) -> torch.Tensor:
|
| 107 |
+
contrastive_features = []
|
| 108 |
+
|
| 109 |
+
for layer_idx in self.contrastive_layers:
|
| 110 |
+
if layer_idx < len(hiddens_list):
|
| 111 |
+
hiddens = hiddens_list[layer_idx]
|
| 112 |
+
attention_masks = attention_masks_list[layer_idx] if attention_masks_list else None
|
| 113 |
+
|
| 114 |
+
layer_features = self._pool_layer_features(
|
| 115 |
+
hiddens, attention_masks, layer_idx
|
| 116 |
+
)
|
| 117 |
+
contrastive_features.append(layer_features)
|
| 118 |
+
|
| 119 |
+
if not contrastive_features:
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
if len(contrastive_features) > 1:
|
| 123 |
+
contrastive_features = torch.cat(contrastive_features, dim=-1)
|
| 124 |
+
else:
|
| 125 |
+
contrastive_features = contrastive_features[0]
|
| 126 |
+
|
| 127 |
+
return contrastive_features
|
| 128 |
+
|
| 129 |
+
def _pool_layer_features(
|
| 130 |
+
self,
|
| 131 |
+
hiddens: torch.Tensor,
|
| 132 |
+
attention_masks: Optional[torch.Tensor],
|
| 133 |
+
layer_idx: int,
|
| 134 |
+
) -> torch.Tensor:
|
| 135 |
+
features = []
|
| 136 |
+
|
| 137 |
+
for i, query in enumerate(self.q[layer_idx]):
|
| 138 |
+
hidden = hiddens[:, i]
|
| 139 |
+
|
| 140 |
+
if self.attention_pooling[layer_idx]:
|
| 141 |
+
attention_scores = torch.matmul(hidden, query.transpose(-1, -2)).squeeze(-1)
|
| 142 |
+
|
| 143 |
+
if attention_masks is not None:
|
| 144 |
+
attention_mask = attention_masks[:, i]
|
| 145 |
+
attention_scores = attention_scores.masked_fill(
|
| 146 |
+
attention_mask == 0, float('-inf')
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
attention_weights = torch.nn.functional.softmax(attention_scores, dim=-1)
|
| 150 |
+
pooled = torch.sum(hidden * attention_weights.unsqueeze(-1), dim=1)
|
| 151 |
+
else:
|
| 152 |
+
if attention_masks is not None:
|
| 153 |
+
attention_mask = attention_masks[:, i]
|
| 154 |
+
mask_expanded = attention_mask.unsqueeze(-1)
|
| 155 |
+
hidden_masked = hidden * mask_expanded
|
| 156 |
+
sum_pooled = torch.sum(hidden_masked, dim=1)
|
| 157 |
+
lengths = torch.sum(mask_expanded, dim=1)
|
| 158 |
+
pooled = sum_pooled / lengths.clamp(min=1e-9)
|
| 159 |
+
else:
|
| 160 |
+
pooled = torch.mean(hidden, dim=1)
|
| 161 |
+
|
| 162 |
+
features.append(pooled)
|
| 163 |
+
|
| 164 |
+
layer_features = torch.cat(features, dim=-1)
|
| 165 |
+
return layer_features
|
| 166 |
+
|
| 167 |
+
def forward(
|
| 168 |
+
self,
|
| 169 |
+
hiddens_list: List[torch.Tensor],
|
| 170 |
+
attention_masks_list: List[torch.Tensor] = None,
|
| 171 |
+
return_projection: bool = False,
|
| 172 |
+
**kwargs,
|
| 173 |
+
) -> torch.Tensor:
|
| 174 |
+
final = []
|
| 175 |
+
|
| 176 |
+
for index, (hiddens, attention_masks) in enumerate(zip(hiddens_list, attention_masks_list)):
|
| 177 |
+
context = []
|
| 178 |
+
for i, query in enumerate(self.q[index]):
|
| 179 |
+
hidden = hiddens[:, i]
|
| 180 |
+
attention_mask = (
|
| 181 |
+
attention_masks[:, i]
|
| 182 |
+
if attention_masks is not None
|
| 183 |
+
else torch.ones(hidden.shape[:2])
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
if self.attention_pooling[index]:
|
| 187 |
+
attention_scores = torch.matmul(
|
| 188 |
+
hidden, query.transpose(-1, -2)
|
| 189 |
+
).squeeze(-1)
|
| 190 |
+
if attention_masks is not None:
|
| 191 |
+
attention_mask = attention_masks[:, i]
|
| 192 |
+
attention_scores = attention_scores.masked_fill(
|
| 193 |
+
attention_mask == 0, float('-inf')
|
| 194 |
+
)
|
| 195 |
+
attention_weights = torch.nn.functional.softmax(
|
| 196 |
+
attention_scores, dim=-1
|
| 197 |
+
)
|
| 198 |
+
hidden = torch.sum(
|
| 199 |
+
hidden * attention_weights.unsqueeze(-1), dim=1
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
if hidden.ndim == 4:
|
| 203 |
+
valid_counts = attention_mask.sum(dim=1)
|
| 204 |
+
last_valid_idx = valid_counts - 1
|
| 205 |
+
hidden = hidden[:, last_valid_idx, :]
|
| 206 |
+
|
| 207 |
+
hidden = self.layer_classifiers[index](hidden)
|
| 208 |
+
context.append(hidden)
|
| 209 |
+
|
| 210 |
+
context_vector = torch.concat(context, dim=1)
|
| 211 |
+
final.append(context_vector)
|
| 212 |
+
|
| 213 |
+
final_vector = torch.cat(final, dim=-1)
|
| 214 |
+
logits = self.classifier(final_vector.float())
|
| 215 |
+
|
| 216 |
+
if self.use_contrastive and return_projection:
|
| 217 |
+
# Get projection for contrastive loss
|
| 218 |
+
projection = self.get_contrastive_projection(
|
| 219 |
+
hiddens_list, attention_masks_list
|
| 220 |
+
)
|
| 221 |
+
return projection, logits
|
| 222 |
+
|
| 223 |
+
return logits
|
| 224 |
+
|
| 225 |
+
def get_contrastive_projection(
|
| 226 |
+
self,
|
| 227 |
+
hiddens_list: List[torch.Tensor],
|
| 228 |
+
attention_masks_list: List[torch.Tensor] = None,
|
| 229 |
+
) -> torch.Tensor:
|
| 230 |
+
if not self.use_contrastive:
|
| 231 |
+
raise ValueError('Model was not initialized with use_contrastive=True')
|
| 232 |
+
|
| 233 |
+
contrastive_features = self._extract_contrastive_features(
|
| 234 |
+
hiddens_list, attention_masks_list
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
if contrastive_features is None:
|
| 238 |
+
final_vector = self._get_final_vector(hiddens_list, attention_masks_list)
|
| 239 |
+
projection = self.projection_head(final_vector)
|
| 240 |
+
else:
|
| 241 |
+
projection = self.projection_head(contrastive_features)
|
| 242 |
+
|
| 243 |
+
return projection
|
| 244 |
+
|
| 245 |
+
def forward_with_projection(
|
| 246 |
+
self,
|
| 247 |
+
hiddens_list: List[torch.Tensor],
|
| 248 |
+
attention_masks_list: List[torch.Tensor] = None,
|
| 249 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 250 |
+
return self.forward(
|
| 251 |
+
hiddens_list,
|
| 252 |
+
attention_masks_list,
|
| 253 |
+
return_projection=True
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
def _get_final_vector(
|
| 257 |
+
self,
|
| 258 |
+
hiddens_list: List[torch.Tensor],
|
| 259 |
+
attention_masks_list: List[torch.Tensor] = None,
|
| 260 |
+
) -> torch.Tensor:
|
| 261 |
+
final = []
|
| 262 |
+
|
| 263 |
+
for index, (hiddens, attention_masks) in enumerate(zip(hiddens_list, attention_masks_list)):
|
| 264 |
+
context = []
|
| 265 |
+
for i, query in enumerate(self.q[index]):
|
| 266 |
+
hidden = hiddens[:, i]
|
| 267 |
+
attention_mask = (
|
| 268 |
+
attention_masks[:, i]
|
| 269 |
+
if attention_masks is not None
|
| 270 |
+
else torch.ones(hidden.shape[:2])
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
if self.attention_pooling[index]:
|
| 274 |
+
attention_scores = torch.matmul(
|
| 275 |
+
hidden, query.transpose(-1, -2)
|
| 276 |
+
).squeeze(-1)
|
| 277 |
+
if attention_masks is not None:
|
| 278 |
+
attention_mask = attention_masks[:, i]
|
| 279 |
+
attention_scores = attention_scores.masked_fill(
|
| 280 |
+
attention_mask == 0, float('-inf')
|
| 281 |
+
)
|
| 282 |
+
attention_weights = torch.nn.functional.softmax(
|
| 283 |
+
attention_scores, dim=-1
|
| 284 |
+
)
|
| 285 |
+
hidden = torch.sum(
|
| 286 |
+
hidden * attention_weights.unsqueeze(-1), dim=1
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
if hidden.ndim == 4:
|
| 290 |
+
valid_counts = attention_mask.sum(dim=1)
|
| 291 |
+
last_valid_idx = valid_counts - 1
|
| 292 |
+
hidden = hidden[:, last_valid_idx, :]
|
| 293 |
+
|
| 294 |
+
context.append(hidden)
|
| 295 |
+
|
| 296 |
+
context_vector = torch.concat(context, dim=1)
|
| 297 |
+
final.append(context_vector)
|
| 298 |
+
|
| 299 |
+
final_vector = torch.cat(final, dim=-1)
|
| 300 |
+
return final_vector
|
| 301 |
+
|
| 302 |
+
def get_layer_features(
|
| 303 |
+
self,
|
| 304 |
+
hiddens_list: List[torch.Tensor],
|
| 305 |
+
attention_masks_list: List[torch.Tensor] = None,
|
| 306 |
+
layer_indices: Optional[List[int]] = None,
|
| 307 |
+
) -> List[torch.Tensor]:
|
| 308 |
+
"""
|
| 309 |
+
Extracts features at specified layers for multi-level contrastive learning.
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
hiddens_list: List of hidden states from different layers
|
| 313 |
+
attention_masks_list: List of attention masks
|
| 314 |
+
layer_indices: Indices of layers to extract features from (None = all layers)
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
List of feature tensors, one per specified layer
|
| 318 |
+
"""
|
| 319 |
+
if layer_indices is None:
|
| 320 |
+
layer_indices = list(range(len(hiddens_list)))
|
| 321 |
+
|
| 322 |
+
layer_features = []
|
| 323 |
+
|
| 324 |
+
for layer_idx in layer_indices:
|
| 325 |
+
if layer_idx >= len(hiddens_list):
|
| 326 |
+
continue
|
| 327 |
+
|
| 328 |
+
hiddens = hiddens_list[layer_idx]
|
| 329 |
+
attention_masks = attention_masks_list[layer_idx] if attention_masks_list else None
|
| 330 |
+
|
| 331 |
+
# Pool features for this layer
|
| 332 |
+
features = self._pool_layer_features(hiddens, attention_masks, layer_idx)
|
| 333 |
+
layer_features.append(features)
|
| 334 |
+
|
| 335 |
+
return layer_features
|
sirin/configs/detector/probing.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
_target_: sirin.detection.probing.SequenceTabPFNProbingDetector
|
| 2 |
+
config:
|
| 3 |
+
num_cpus: 1
|
| 4 |
+
embedding_dim: null
|
| 5 |
+
num_features: null
|
| 6 |
+
dropout_rate: 0.1
|
| 7 |
+
ensemble: null
|
| 8 |
+
# feature_processor will be passed during instantiation
|
sirin/configs/feature_processor/hiddens.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
_target_: sirin.detection.processors.hidden.HiddensProcessor
|
| 2 |
+
config:
|
| 3 |
+
feature_cache_dir: "${oc.env:HOME}/.cache/feature_cache"
|
| 4 |
+
token_locator_config: ${token_locator_config}
|
| 5 |
+
layers: null
|
| 6 |
+
separate: false
|
| 7 |
+
cache_dir: ${cache_dir}
|
sirin/configs/model_adapter/default.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
_target_: sirin.inference.adapters.HfModelAdapter
|
| 2 |
+
config:
|
| 3 |
+
model_path: "Qwen/Qwen2.5-3B-Instruct"
|
| 4 |
+
device: "cuda"
|
| 5 |
+
|
| 6 |
+
# Optional: batch_size for automatic batch splitting (prevents OOM)
|
| 7 |
+
# batch_size: 8 # Uncomment to enable batch processing
|
| 8 |
+
# See: sirin/configs/model_adapter/hf_with_batching.yaml for example
|
sirin/configs/model_adapter/hf_large_model.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace adapter for large models (7B+)
|
| 2 |
+
# Small batch size + multi-GPU support
|
| 3 |
+
|
| 4 |
+
_target_: sirin.inference.adapters.HfModelAdapter
|
| 5 |
+
config:
|
| 6 |
+
model_path: "meta-llama/Llama-2-7b-chat-hf"
|
| 7 |
+
device: "cuda"
|
| 8 |
+
batch_size: 4 # Small batch size for large models
|
| 9 |
+
|
| 10 |
+
# Memory optimization
|
| 11 |
+
max_length: 1024
|
| 12 |
+
model_dtype: "bf16"
|
| 13 |
+
low_cpu_mem_usage: true
|
| 14 |
+
|
| 15 |
+
# Multi-GPU distribution
|
| 16 |
+
device_map: "auto" # Automatically distribute across GPUs
|
| 17 |
+
# max_memory: {0: "20GiB", 1: "20GiB"} # Uncomment to set per-GPU limits
|
sirin/configs/model_adapter/hf_with_batching.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace adapter with automatic batch processing
|
| 2 |
+
# Use this config for memory-efficient inference with large batches
|
| 3 |
+
|
| 4 |
+
_target_: sirin.inference.adapters.HfModelAdapter
|
| 5 |
+
config:
|
| 6 |
+
model_path: "Qwen/Qwen2.5-3B-Instruct"
|
| 7 |
+
device: "cuda"
|
| 8 |
+
batch_size: 8 # Process 8 samples at a time to prevent OOM
|
| 9 |
+
|
| 10 |
+
# Optional: Additional HF-specific parameters
|
| 11 |
+
max_length: 2048
|
| 12 |
+
model_dtype: "bf16"
|
| 13 |
+
padding: "max_length"
|
| 14 |
+
truncation: true
|
| 15 |
+
|
| 16 |
+
# Optional: Multi-GPU support
|
| 17 |
+
# device_map: "auto" # Uncomment for multi-GPU inference
|
| 18 |
+
# max_memory: {0: "20GiB", 1: "20GiB"} # Per-GPU memory limits
|
sirin/configs/model_adapter/openai_with_parallel.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenAI adapter with parallel async processing
|
| 2 |
+
# Processes multiple requests concurrently for 10x speedup
|
| 3 |
+
|
| 4 |
+
_target_: sirin.inference.adapters.OpenAIModelAdapter
|
| 5 |
+
config:
|
| 6 |
+
model_path: "gpt-4"
|
| 7 |
+
base_url: "https://api.openai.com/v1"
|
| 8 |
+
# batch_size: null # Not used for OpenAI (uses async instead)
|
| 9 |
+
|
| 10 |
+
# API parameters
|
| 11 |
+
max_retries: 3
|
| 12 |
+
timeout: 60
|
| 13 |
+
|
| 14 |
+
# Note: Use use_async=True and max_concurrent in sample() call
|
| 15 |
+
# Example:
|
| 16 |
+
# adapter.sample(inputs, use_async=True, max_concurrent=10)
|
sirin/configs/model_adapter/openrouter.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenRouter adapter (OpenAI-compatible API)
|
| 2 |
+
# Use this for accessing various models through OpenRouter
|
| 3 |
+
|
| 4 |
+
_target_: sirin.inference.adapters.OpenAIModelAdapter
|
| 5 |
+
config:
|
| 6 |
+
model_path: "meta-llama/llama-2-70b-chat" # Or any OpenRouter model
|
| 7 |
+
base_url: "https://openrouter.ai/api/v1"
|
| 8 |
+
|
| 9 |
+
# API parameters
|
| 10 |
+
max_retries: 3
|
| 11 |
+
timeout: 60
|
| 12 |
+
proxy_url: null # Optional: HTTP proxy
|
| 13 |
+
|
| 14 |
+
# Note: Parallel processing via use_async=True in sample() call
|
| 15 |
+
# adapter.sample(inputs, use_async=True, max_concurrent=5)
|
sirin/configs/model_adapter/vllm_with_batching.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# vLLM adapter with batch processing
|
| 2 |
+
# vLLM provides high-throughput inference with efficient batching
|
| 3 |
+
|
| 4 |
+
_target_: sirin.inference.adapters.VllmModelAdapter
|
| 5 |
+
config:
|
| 6 |
+
model_path: "meta-llama/Llama-2-7b-chat-hf"
|
| 7 |
+
device: "cuda"
|
| 8 |
+
batch_size: 16 # vLLM can handle larger batches efficiently
|
| 9 |
+
|
| 10 |
+
# vLLM-specific parameters
|
| 11 |
+
max_length: 2048
|
| 12 |
+
enforce_eager: true
|
| 13 |
+
gpu_memory_utilization: 0.85 # Use 85% of GPU memory
|
| 14 |
+
|
| 15 |
+
# Optional: Additional model kwargs
|
| 16 |
+
# model_kwargs:
|
| 17 |
+
# tensor_parallel_size: 2 # For multi-GPU
|
| 18 |
+
# dtype: "bfloat16"
|
sirin/configs/pipeline/probing.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
_target_: sirin.detection.probing.ProbingPipeline
|
| 2 |
+
config:
|
| 3 |
+
save_dir: "./outputs"
|
| 4 |
+
sampling:
|
| 5 |
+
temperature: 1.0
|
| 6 |
+
top_p: 1.0
|
| 7 |
+
top_k: 50
|
| 8 |
+
max_length: 512
|
| 9 |
+
do_sample: true
|
| 10 |
+
model_manager:
|
| 11 |
+
max_active_models: 3
|
| 12 |
+
memory_threshold: 0.8
|
| 13 |
+
max_retries: 3
|
| 14 |
+
retry_delay: 1.0
|
| 15 |
+
save_intermediate: true
|
| 16 |
+
experiment_name: "example_probe"
|
| 17 |
+
f_beta: 1.0
|
| 18 |
+
train_args: ${train_args}
|
| 19 |
+
|
| 20 |
+
task_type: ${task_type}
|
| 21 |
+
target_approximator: null
|
| 22 |
+
experiment_logger: null
|
| 23 |
+
# detector, train_dataset, eval_dataset, and generator_adapter will be passed during instantiation
|
sirin/configs/token_locator_config/default.yaml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
locate_answer_start: true
|
sirin/configs/train.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Top-level config which composes smaller configs via Hydra defaults
|
| 2 |
+
defaults:
|
| 3 |
+
- model_adapter: default
|
| 4 |
+
- token_locator_config: default
|
| 5 |
+
- feature_processor: hiddens
|
| 6 |
+
- detector: probing
|
| 7 |
+
- pipeline: probing
|
| 8 |
+
- train_args: default
|
| 9 |
+
|
| 10 |
+
# Runtime params
|
| 11 |
+
cuda_visible_devices: "0"
|
| 12 |
+
cache_dir: "./cache"
|
| 13 |
+
use_cache: true
|
| 14 |
+
task_type: null
|
| 15 |
+
log_level: DEBUG
|
| 16 |
+
|
| 17 |
+
random_seed: 42
|
| 18 |
+
change_nested_random_seeds: true
|
| 19 |
+
|
| 20 |
+
train_dataset_path: ???
|
| 21 |
+
eval_dataset_path: ???
|
| 22 |
+
|
| 23 |
+
hf_token : null
|
| 24 |
+
openai_api_key: null
|
sirin/configs/train_args/default.yaml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
max_epochs: 5
|
| 2 |
+
validation_interval: 1
|
| 3 |
+
val_size: 0.1
|
| 4 |
+
learning_rate: 0.001
|
| 5 |
+
train_batch_size: 8
|
| 6 |
+
val_batch_size: 8
|
| 7 |
+
threshold: 0.5
|
| 8 |
+
loss_function: "bce"
|
| 9 |
+
alpha_scheduler: "exp"
|
| 10 |
+
metrics: null
|
sirin/definitions/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .constants import *
|
| 2 |
+
from .enums import *
|
| 3 |
+
from .types import *
|
sirin/definitions/constants.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
HF_TOKEN_ENV = "HF_TOKEN"
|
| 2 |
+
OPENAI_API_KEY_ENV = "OPENAI_API_KEY"
|
| 3 |
+
|
| 4 |
+
INPUT_COL = "input"
|
| 5 |
+
INPUT_PROC_COL = "processed_input"
|
| 6 |
+
REFERENCE_COL = "reference"
|
| 7 |
+
TARGET_COL = "target"
|
| 8 |
+
ANSWER_INDICES = "answer_indices"
|
| 9 |
+
OFFSETS_COL = "offsets"
|
| 10 |
+
GROUP_ID_COL = "group_id"
|
| 11 |
+
|
| 12 |
+
GENERATED_DSET_KEY = "generated_dataset"
|
| 13 |
+
CLEANED_DSET_KEY = "cleaned_dataset"
|
| 14 |
+
DSET_KEY = "dataset"
|
| 15 |
+
METRICS_KEY = "metrics"
|
| 16 |
+
TORCH_MODULE_KEY = "torch_module"
|
| 17 |
+
HIDDENS_FILE_KEY = "hidden_file"
|
| 18 |
+
|
| 19 |
+
ARTIFACTS_YAML = "artifacts.yaml"
|
| 20 |
+
SAVE_FEAT_TEMPLATE = "{feature_type}_{sample_hash}_layer_{layer_idx}.pkl"
|
sirin/definitions/enums.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
from typing import Callable
|
| 3 |
+
|
| 4 |
+
from sklearn.metrics import (
|
| 5 |
+
accuracy_score,
|
| 6 |
+
auc,
|
| 7 |
+
f1_score,
|
| 8 |
+
fbeta_score,
|
| 9 |
+
precision_recall_curve,
|
| 10 |
+
precision_score,
|
| 11 |
+
recall_score,
|
| 12 |
+
roc_auc_score,
|
| 13 |
+
average_precision_score
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class LogLevel(Enum):
|
| 18 |
+
DEBUG = 'debug'
|
| 19 |
+
INFO = 'info'
|
| 20 |
+
WARNING = 'warning'
|
| 21 |
+
ERROR = 'error'
|
| 22 |
+
CRITICAL = 'critical'
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ModelType(Enum):
|
| 26 |
+
CAUSAL = 'causal'
|
| 27 |
+
BASE = 'base'
|
| 28 |
+
TOKEN_CLASSIFICATION = 'token_classification'
|
| 29 |
+
SEQUENCE_CLASSIFICATION = 'sequence_classification'
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ModelAdapterType(Enum):
|
| 33 |
+
HUGGINGFACE = 'hf'
|
| 34 |
+
VLLM = 'vllm'
|
| 35 |
+
OPENAI = 'openai'
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class DetectionTaskType(Enum):
|
| 39 |
+
HALLUCINATION_DETECTION = 'hallucination'
|
| 40 |
+
QUERY_ANSWERABILITY = 'answerability'
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class FeatureType(Enum):
|
| 44 |
+
HIDDEN = 'hidden'
|
| 45 |
+
ATTENTION = 'attention'
|
| 46 |
+
LOOKBACK = 'lookback'
|
| 47 |
+
LOGIT = 'logit'
|
| 48 |
+
SUBLAYER = 'sublayer'
|
| 49 |
+
TOKEN_UNCERTAINTY = 'token_uncertainty'
|
| 50 |
+
SEQUENCE_UNCERTAINTY = 'sequence_uncertainty'
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SplitStrategy(Enum):
|
| 54 |
+
SENTENCE = 'sentence'
|
| 55 |
+
PARAGRAPH = 'paragraph'
|
| 56 |
+
CHARACTER = 'character'
|
| 57 |
+
LANGCHAIN = 'langchain'
|
| 58 |
+
ATOMIC = 'atomic_facts'
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class AggregationMethod(Enum):
|
| 62 |
+
MEAN = 'mean'
|
| 63 |
+
MAX = 'max'
|
| 64 |
+
MIN = 'min'
|
| 65 |
+
VOTE = 'vote'
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class SideType(Enum):
|
| 69 |
+
LEFT = 'left'
|
| 70 |
+
RIGHT = 'right'
|
| 71 |
+
INNER = 'inner'
|
| 72 |
+
OUTER = 'outer'
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class AggregationType(Enum):
|
| 76 |
+
MICRO = 'micro'
|
| 77 |
+
MACRO = 'macro'
|
| 78 |
+
CONCAT = 'concat'
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class DetectionLevel(Enum):
|
| 82 |
+
TOKEN = 'token'
|
| 83 |
+
SEQUENCE = 'sequence'
|
| 84 |
+
CLAIM = 'claim'
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class DataCollatorType(Enum):
|
| 88 |
+
"""Data collator types for model training.
|
| 89 |
+
|
| 90 |
+
- TOKEN: For token-level classification with -100 padding mask
|
| 91 |
+
- PADDING: For sequence-level classification with scalar labels
|
| 92 |
+
"""
|
| 93 |
+
TOKEN = 'token'
|
| 94 |
+
PADDING = 'padding'
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Phase(Enum):
|
| 98 |
+
TRAIN = 'train'
|
| 99 |
+
TEST = 'test'
|
| 100 |
+
VAL = 'val'
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class CompressionMethod(Enum):
|
| 104 |
+
"""Supported compression methods."""
|
| 105 |
+
|
| 106 |
+
PCA = 'pca'
|
| 107 |
+
UMAP = 'umap'
|
| 108 |
+
NONE = 'none'
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class ScalingMethod(Enum):
|
| 112 |
+
"""Supported scaling methods."""
|
| 113 |
+
|
| 114 |
+
STANDARD = 'standard' # Z-score normalization: (x - mean) / std
|
| 115 |
+
MINMAX = 'minmax' # Min-Max scaling: (x - min) / (max - min)
|
| 116 |
+
ROBUST = 'robust' # Robust scaling using median and IQR
|
| 117 |
+
NONE = 'none' # No scaling
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class LmMetric(Enum):
|
| 121 |
+
"""Standard NLP evaluation metrics constants."""
|
| 122 |
+
|
| 123 |
+
ROUGE_1 = 'rouge_1'
|
| 124 |
+
ROUGE_2 = 'rouge_2'
|
| 125 |
+
ROUGE_L = 'rouge_l'
|
| 126 |
+
BLEU = 'bleu'
|
| 127 |
+
TER = 'ter'
|
| 128 |
+
|
| 129 |
+
BERT_SCORE = 'bert_score'
|
| 130 |
+
BERT_SCORE_PRECISION = 'bert_score_precision'
|
| 131 |
+
BERT_SCORE_RECALL = 'bert_score_recall'
|
| 132 |
+
BERT_SCORE_F1 = 'bert_score_f1'
|
| 133 |
+
METEOR = 'meteor'
|
| 134 |
+
|
| 135 |
+
@classmethod
|
| 136 |
+
def get_rouge_metrics(cls) -> list['LmMetric']:
|
| 137 |
+
"""Get all ROUGE-based metrics."""
|
| 138 |
+
return [cls.ROUGE_1, cls.ROUGE_2, cls.ROUGE_L]
|
| 139 |
+
|
| 140 |
+
@classmethod
|
| 141 |
+
def get_bert_metrics(cls) -> list['LmMetric']:
|
| 142 |
+
"""Get all BERT-based metrics."""
|
| 143 |
+
return [cls.BERT_SCORE_PRECISION, cls.BERT_SCORE_RECALL, cls.BERT_SCORE_F1]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class ClassificationMetric(Enum):
|
| 147 |
+
ROC_AUC = 'roc_auc'
|
| 148 |
+
F1 = 'f1'
|
| 149 |
+
ACCURACY = 'accuracy'
|
| 150 |
+
PR_AUC = 'pr_auc'
|
| 151 |
+
PRECISION = 'precision'
|
| 152 |
+
RECALL = 'recall'
|
| 153 |
+
FBETA = 'fbeta'
|
| 154 |
+
AP = 'average_precision'
|
| 155 |
+
|
| 156 |
+
@staticmethod
|
| 157 |
+
def get_metric_function(metric: str) -> Callable:
|
| 158 |
+
def pr_auc(y, y_pred_score):
|
| 159 |
+
precision, recall, _ = precision_recall_curve(y, y_pred_score)
|
| 160 |
+
return auc(recall, precision)
|
| 161 |
+
|
| 162 |
+
if metric == ClassificationMetric.ROC_AUC.value:
|
| 163 |
+
return roc_auc_score
|
| 164 |
+
elif metric == ClassificationMetric.F1.value:
|
| 165 |
+
return f1_score
|
| 166 |
+
elif metric == ClassificationMetric.ACCURACY.value:
|
| 167 |
+
return accuracy_score
|
| 168 |
+
elif metric == ClassificationMetric.PR_AUC.value:
|
| 169 |
+
return pr_auc
|
| 170 |
+
elif metric == ClassificationMetric.PRECISION.value:
|
| 171 |
+
return precision_score
|
| 172 |
+
elif metric == ClassificationMetric.RECALL.value:
|
| 173 |
+
return recall_score
|
| 174 |
+
elif metric == ClassificationMetric.FBETA.value:
|
| 175 |
+
return fbeta_score
|
| 176 |
+
elif metric == ClassificationMetric.AP.value:
|
| 177 |
+
return average_precision_score
|
| 178 |
+
else:
|
| 179 |
+
raise NotImplementedError(f'Unsupported metric: {metric}')
|
| 180 |
+
|
| 181 |
+
def __str__(self):
|
| 182 |
+
return self.value
|
| 183 |
+
|
| 184 |
+
def startswith(self, prefix):
|
| 185 |
+
return self.value.startswith(prefix)
|
| 186 |
+
|
| 187 |
+
BASIC_METRICS = [
|
| 188 |
+
ClassificationMetric.F1,
|
| 189 |
+
ClassificationMetric.ROC_AUC,
|
| 190 |
+
ClassificationMetric.ACCURACY,
|
| 191 |
+
ClassificationMetric.PR_AUC,
|
| 192 |
+
ClassificationMetric.PRECISION,
|
| 193 |
+
ClassificationMetric.RECALL,
|
| 194 |
+
ClassificationMetric.AP
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
class TokenLocation(Enum):
|
| 198 |
+
ANS_END = 'answer_end'
|
| 199 |
+
ANS_START = 'answer_start'
|
| 200 |
+
ANS_MID = 'answer_middle'
|
| 201 |
+
EOS = 'eos'
|
| 202 |
+
N_TO_ANS_START = 'n_to_answer_start'
|
| 203 |
+
N_TO_ANS_END = 'n_to_answer_end'
|
| 204 |
+
SUBSTRING = 'substring'
|
sirin/definitions/types.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
from typing import Union
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
from omegaconf import DictConfig, ListConfig
|
| 6 |
+
|
| 7 |
+
CfgDictType = Union[dict, DictConfig]
|
| 8 |
+
CfgListType = Union[list, ListConfig]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TorchDtype(Enum):
|
| 12 |
+
bf16 = torch.bfloat16
|
| 13 |
+
f64 = torch.float64
|
| 14 |
+
f32 = torch.float32
|
| 15 |
+
f16 = torch.float16
|
| 16 |
+
c32 = torch.complex32
|
| 17 |
+
c64 = torch.complex64
|
| 18 |
+
c128 = torch.complex128
|
| 19 |
+
i8 = torch.int8
|
| 20 |
+
i16 = torch.int16
|
| 21 |
+
i32 = torch.int32
|
| 22 |
+
i64 = torch.int64
|
| 23 |
+
b8 = torch.bool
|
| 24 |
+
u8 = torch.uint8
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def from_str(dtype_name: str) -> torch.dtype:
|
| 28 |
+
return TorchDtype[dtype_name].value
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def from_dtype(dtype: torch.dtype) -> str:
|
| 32 |
+
for dtype_member in TorchDtype:
|
| 33 |
+
if dtype_member.value == dtype:
|
| 34 |
+
return dtype_member.name
|
| 35 |
+
raise ValueError(f"Unknown dtype: {dtype}.")
|
sirin/detection/__init__.py
ADDED
|
File without changes
|
sirin/detection/_lm_polygraph_compat.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import types
|
| 3 |
+
|
| 4 |
+
from transformers import AutoProcessor, GenerationConfig
|
| 5 |
+
import transformers
|
| 6 |
+
transformers = sys.modules['transformers']
|
| 7 |
+
|
| 8 |
+
if not hasattr(transformers, 'HybridCache') and hasattr(transformers, 'DynamicCache'):
|
| 9 |
+
transformers.HybridCache = transformers.DynamicCache
|
| 10 |
+
|
| 11 |
+
from transformers.generation.utils import (
|
| 12 |
+
GenerateBeamDecoderOnlyOutput,
|
| 13 |
+
GenerateDecoderOnlyOutput,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
gen_utils = sys.modules['transformers.generation.utils']
|
| 17 |
+
|
| 18 |
+
# ensemble_beam.py: from transformers.generation.beam_search import BeamScorer
|
| 19 |
+
# Module was removed in transformers 5.x
|
| 20 |
+
if 'transformers.generation.beam_search' not in sys.modules:
|
| 21 |
+
try:
|
| 22 |
+
from transformers.utils.dummy_pt_objects import BeamScorer as _BeamScorer
|
| 23 |
+
except ImportError:
|
| 24 |
+
_BeamScorer = None
|
| 25 |
+
|
| 26 |
+
if _BeamScorer is not None:
|
| 27 |
+
beam_search_mod = types.ModuleType('transformers.generation.beam_search')
|
| 28 |
+
beam_search_mod.BeamScorer = _BeamScorer
|
| 29 |
+
sys.modules['transformers.generation.beam_search'] = beam_search_mod
|
| 30 |
+
|
| 31 |
+
# ensemble_beam.py: BeamSearchOutput, BeamSearchDecoderOnlyOutput
|
| 32 |
+
if not hasattr(gen_utils, 'BeamSearchOutput'):
|
| 33 |
+
gen_utils.BeamSearchOutput = GenerateBeamDecoderOnlyOutput
|
| 34 |
+
if not hasattr(gen_utils, 'BeamSearchDecoderOnlyOutput'):
|
| 35 |
+
gen_utils.BeamSearchDecoderOnlyOutput = GenerateBeamDecoderOnlyOutput
|
| 36 |
+
|
| 37 |
+
# ensemble_greedy.py: GreedySearchOutput, GreedySearchDecoderOnlyOutput
|
| 38 |
+
if not hasattr(gen_utils, 'GreedySearchOutput'):
|
| 39 |
+
gen_utils.GreedySearchOutput = GenerateDecoderOnlyOutput
|
| 40 |
+
if not hasattr(gen_utils, 'GreedySearchDecoderOnlyOutput'):
|
| 41 |
+
gen_utils.GreedySearchDecoderOnlyOutput = GenerateDecoderOnlyOutput
|
| 42 |
+
|
| 43 |
+
# ensemble_sample.py: SampleOutput, SampleDecoderOnlyOutput
|
| 44 |
+
if not hasattr(gen_utils, 'SampleOutput'):
|
| 45 |
+
gen_utils.SampleOutput = GenerateDecoderOnlyOutput
|
| 46 |
+
if not hasattr(gen_utils, 'SampleDecoderOnlyOutput'):
|
| 47 |
+
gen_utils.SampleDecoderOnlyOutput = GenerateDecoderOnlyOutput
|
| 48 |
+
|
| 49 |
+
# visual_whitebox_model.py: AutoModelForVision2Seq
|
| 50 |
+
if not hasattr(transformers, 'AutoModelForVision2Seq'):
|
| 51 |
+
transformers.AutoModelForVision2Seq = transformers.AutoModelForImageTextToText
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# greedy_probs.py: out.scores are raw logits, not log-probabilities.
|
| 55 |
+
# GreedyProbsCalculator treats them as log-probs, so Perplexity/MaxSequenceProb
|
| 56 |
+
# get garbage values (and inf from -inf logits in MoE models).
|
| 57 |
+
# VisualWhiteboxModel has a _ScoresProcessor that applies log_softmax, but
|
| 58 |
+
# WhiteboxModelBasic does not.
|
| 59 |
+
import torch as _torch
|
| 60 |
+
from lm_polygraph.model_adapters.whitebox_model_basic import WhiteboxModelBasic
|
| 61 |
+
|
| 62 |
+
_original_wb_generate = WhiteboxModelBasic.generate
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _patched_wb_generate(self, *args, **kwargs):
|
| 66 |
+
out = _original_wb_generate(self, *args, **kwargs)
|
| 67 |
+
if hasattr(out, 'scores') and out.scores is not None:
|
| 68 |
+
out.scores = tuple(s.log_softmax(dim=-1) for s in out.scores)
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
WhiteboxModelBasic.generate = _patched_wb_generate
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
import openai as _openai
|
| 76 |
+
from lm_polygraph.utils.model import BlackboxModel
|
| 77 |
+
|
| 78 |
+
_original_blackbox_init = BlackboxModel.__init__
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _patched_blackbox_init(self, openai_api_key=None, model_path=None,
|
| 82 |
+
hf_api_token=None, generation_parameters=None,
|
| 83 |
+
supports_logprobs=False, base_url=None):
|
| 84 |
+
if generation_parameters is None:
|
| 85 |
+
from lm_polygraph.utils.generation_parameters import GenerationParameters
|
| 86 |
+
generation_parameters = GenerationParameters()
|
| 87 |
+
_original_blackbox_init(
|
| 88 |
+
self,
|
| 89 |
+
openai_api_key=openai_api_key,
|
| 90 |
+
model_path=model_path,
|
| 91 |
+
hf_api_token=hf_api_token,
|
| 92 |
+
generation_parameters=generation_parameters,
|
| 93 |
+
supports_logprobs=supports_logprobs,
|
| 94 |
+
)
|
| 95 |
+
self.base_url = base_url
|
| 96 |
+
if openai_api_key is not None and base_url is not None:
|
| 97 |
+
self.openai_api = _openai.OpenAI(api_key=openai_api_key, base_url=base_url)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
BlackboxModel.__init__ = _patched_blackbox_init
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _patched_from_openai(openai_api_key=None, model_path=None,
|
| 105 |
+
supports_logprobs=False, **kwargs):
|
| 106 |
+
from lm_polygraph.utils.generation_parameters import GenerationParameters
|
| 107 |
+
generation_parameters = kwargs.pop('generation_parameters', GenerationParameters())
|
| 108 |
+
base_url = kwargs.pop('base_url', None)
|
| 109 |
+
return BlackboxModel(
|
| 110 |
+
openai_api_key=openai_api_key,
|
| 111 |
+
model_path=model_path,
|
| 112 |
+
supports_logprobs=supports_logprobs,
|
| 113 |
+
generation_parameters=generation_parameters,
|
| 114 |
+
base_url=base_url,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
BlackboxModel.from_openai = _patched_from_openai
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def load_stat_calculator(cfg, env):
|
| 122 |
+
from lm_polygraph.stat_calculators.greedy_probs_blackbox import BlackboxGreedyTextsCalculator
|
| 123 |
+
top_logprobs = cfg.get('top_logprobs', 5)
|
| 124 |
+
return BlackboxGreedyTextsCalculator(top_logprobs=top_logprobs)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
from lm_polygraph.defaults import register_default_stat_calculators as _reg_module
|
| 129 |
+
|
| 130 |
+
_original_register = _reg_module.register_default_stat_calculators
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _patched_register(model_type, language='en', hf_cache=None,
|
| 134 |
+
blackbox_supports_logprobs=False, top_logprobs=5,
|
| 135 |
+
output_attentions=True, output_hidden_states=True,
|
| 136 |
+
deberta_batch_size=10):
|
| 137 |
+
result = _original_register(
|
| 138 |
+
model_type=model_type,
|
| 139 |
+
language=language,
|
| 140 |
+
hf_cache=hf_cache,
|
| 141 |
+
blackbox_supports_logprobs=blackbox_supports_logprobs,
|
| 142 |
+
output_attentions=output_attentions,
|
| 143 |
+
output_hidden_states=output_hidden_states,
|
| 144 |
+
deberta_batch_size=deberta_batch_size,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
for container in result:
|
| 148 |
+
if container.name == 'BlackboxGreedyTextsCalculator':
|
| 149 |
+
from omegaconf import OmegaConf
|
| 150 |
+
cfg = OmegaConf.to_container(container.cfg, resolve=True)
|
| 151 |
+
cfg['top_logprobs'] = top_logprobs
|
| 152 |
+
container.cfg = OmegaConf.create(cfg)
|
| 153 |
+
container.builder = 'sirin.detection._lm_polygraph_compat'
|
| 154 |
+
return result
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
_reg_module.register_default_stat_calculators = _patched_register
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# greedy_alternatives_nli.py: deberta.deberta_tokenizer.batch_encode_plus(...)
|
| 161 |
+
# DebertaTokenizer removed batch_encode_plus in transformers 5.x.
|
| 162 |
+
# Patch: add it back as a wrapper around __call__ (same semantics).
|
| 163 |
+
from transformers import DebertaTokenizer as _DebertaTokenizer
|
| 164 |
+
|
| 165 |
+
if not hasattr(_DebertaTokenizer, 'batch_encode_plus'):
|
| 166 |
+
def _batch_encode_plus(self, *args, **kwargs):
|
| 167 |
+
return self(*args, **kwargs)
|
| 168 |
+
_DebertaTokenizer.batch_encode_plus = _batch_encode_plus
|
sirin/detection/approximators/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import TargetApproximatorBase
|
| 2 |
+
|
| 3 |
+
__all__ = ["TargetApproximatorBase", "SEPTargetApproximator"]
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def __getattr__(name):
|
| 7 |
+
if name != "SEPTargetApproximator":
|
| 8 |
+
raise AttributeError(name)
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from .sep import SEPTargetApproximator
|
| 12 |
+
except ModuleNotFoundError as exc:
|
| 13 |
+
if exc.name == "lm_polygraph":
|
| 14 |
+
raise ModuleNotFoundError(
|
| 15 |
+
"SEPTargetApproximator requires the optional lm_polygraph dependency"
|
| 16 |
+
) from exc
|
| 17 |
+
raise
|
| 18 |
+
return SEPTargetApproximator
|
sirin/detection/approximators/base.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Any, Dict, List
|
| 3 |
+
|
| 4 |
+
from sirin.inference.adapters import ModelAdapterBase
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TargetApproximatorBase(ABC):
|
| 8 |
+
def __init__(self, extractor: ModelAdapterBase):
|
| 9 |
+
self.extractor = extractor
|
| 10 |
+
|
| 11 |
+
@abstractmethod
|
| 12 |
+
def __call__(self, samples: List[List[Dict]], **kwargs) -> Any:
|
| 13 |
+
pass
|
sirin/detection/approximators/sep.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
from typing import Dict, List, Optional
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
from loguru import logger as lg
|
| 7 |
+
|
| 8 |
+
import sirin.detection._lm_polygraph_compat # noqa: F401 — must be before lm-polygraph imports
|
| 9 |
+
from sirin.detection.utils.math import binarize_entropy
|
| 10 |
+
from sirin.inference.adapters import ModelAdapterBase
|
| 11 |
+
from sirin.detection.approximators import TargetApproximatorBase
|
| 12 |
+
from sirin.metrics.classification import calculate_classification_metrics
|
| 13 |
+
from sirin.definitions import ClassificationMetric
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _log_roc_auc(labels: List[int], scores) -> None:
|
| 17 |
+
try:
|
| 18 |
+
lg.info(
|
| 19 |
+
f"ROC_AUC_SCORE {calculate_classification_metrics(labels, scores, metrics=[ClassificationMetric.ROC_AUC])} #"
|
| 20 |
+
)
|
| 21 |
+
except ValueError as e:
|
| 22 |
+
lg.debug(f"ROC-AUC logging skipped: {e}")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SEPTargetApproximator(TargetApproximatorBase):
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
extractor: ModelAdapterBase,
|
| 29 |
+
num_return_sequences: int = 5,
|
| 30 |
+
sampling_kwargs: Optional[Dict] = None,
|
| 31 |
+
):
|
| 32 |
+
super().__init__(extractor=extractor)
|
| 33 |
+
from lm_polygraph.utils.deberta import Deberta
|
| 34 |
+
|
| 35 |
+
self.nli_model = Deberta(batch_size=8)
|
| 36 |
+
self.stats = {}
|
| 37 |
+
self.num_return_sequences = num_return_sequences
|
| 38 |
+
self.sampling_kwargs = sampling_kwargs or {}
|
| 39 |
+
|
| 40 |
+
def _ensure_logprob_support(self) -> None:
|
| 41 |
+
params = inspect.signature(self.extractor.sample).parameters
|
| 42 |
+
supports_kwargs = any(
|
| 43 |
+
param.kind == inspect.Parameter.VAR_KEYWORD
|
| 44 |
+
for param in params.values()
|
| 45 |
+
)
|
| 46 |
+
if 'return_logprobs' not in params and not supports_kwargs:
|
| 47 |
+
raise NotImplementedError(
|
| 48 |
+
f"SEP requires an adapter with return_logprobs support; got {type(self.extractor).__name__}"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def _sample_with_logprobs(self, samples: List[List[Dict]]):
|
| 52 |
+
self._ensure_logprob_support()
|
| 53 |
+
return self.extractor.sample(
|
| 54 |
+
samples,
|
| 55 |
+
return_logprobs=True,
|
| 56 |
+
num_return_sequences=self.num_return_sequences,
|
| 57 |
+
max_tokens=100,
|
| 58 |
+
temperature=1.0,
|
| 59 |
+
**getattr(self, 'sampling_kwargs', {}),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
@staticmethod
|
| 63 |
+
def _is_token_logprob(token_logprob) -> bool:
|
| 64 |
+
if isinstance(token_logprob, (int, float, np.floating)):
|
| 65 |
+
return True
|
| 66 |
+
if not isinstance(token_logprob, (tuple, list)) or len(token_logprob) == 0:
|
| 67 |
+
return False
|
| 68 |
+
first = token_logprob[0]
|
| 69 |
+
return isinstance(first, (str, int, float, np.floating))
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _is_sequence_logprobs(value) -> bool:
|
| 73 |
+
return isinstance(value, list) and (
|
| 74 |
+
len(value) == 0 or SEPTargetApproximator._is_token_logprob(value[0])
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def _primary_logprob(token_logprob) -> float:
|
| 79 |
+
if isinstance(token_logprob, (int, float, np.floating)):
|
| 80 |
+
return float(token_logprob)
|
| 81 |
+
if len(token_logprob) >= 2 and isinstance(token_logprob[0], str):
|
| 82 |
+
return float(token_logprob[1])
|
| 83 |
+
return float(token_logprob[0])
|
| 84 |
+
|
| 85 |
+
def _normalize_sample_outputs(self, responses, logits, sample_count: int):
|
| 86 |
+
if responses and isinstance(responses[0], str):
|
| 87 |
+
if (
|
| 88 |
+
self.num_return_sequences > 1
|
| 89 |
+
and len(responses) == sample_count * self.num_return_sequences
|
| 90 |
+
):
|
| 91 |
+
sample_texts = [
|
| 92 |
+
responses[i : i + self.num_return_sequences]
|
| 93 |
+
for i in range(0, len(responses), self.num_return_sequences)
|
| 94 |
+
]
|
| 95 |
+
else:
|
| 96 |
+
sample_texts = [[response] for response in responses]
|
| 97 |
+
else:
|
| 98 |
+
sample_texts = responses
|
| 99 |
+
|
| 100 |
+
if (
|
| 101 |
+
logits
|
| 102 |
+
and self.num_return_sequences > 1
|
| 103 |
+
and len(logits) == sample_count * self.num_return_sequences
|
| 104 |
+
and all(self._is_sequence_logprobs(logits_for_sample) for logits_for_sample in logits)
|
| 105 |
+
):
|
| 106 |
+
sample_logits = [
|
| 107 |
+
logits[i : i + self.num_return_sequences]
|
| 108 |
+
for i in range(0, len(logits), self.num_return_sequences)
|
| 109 |
+
]
|
| 110 |
+
else:
|
| 111 |
+
sample_logits = []
|
| 112 |
+
for logits_for_sample in logits:
|
| 113 |
+
if self._is_sequence_logprobs(logits_for_sample):
|
| 114 |
+
sample_logits.append([logits_for_sample])
|
| 115 |
+
else:
|
| 116 |
+
sample_logits.append(logits_for_sample)
|
| 117 |
+
|
| 118 |
+
return sample_texts, self._reshape_logprobs(sample_logits)
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
def _reshape_logprobs(logits) -> List[List[float]]:
|
| 122 |
+
"""Convert adapter logprob output to per-sequence token logprob sums."""
|
| 123 |
+
result = []
|
| 124 |
+
for gen in logits:
|
| 125 |
+
seq_scores = []
|
| 126 |
+
for sample in gen:
|
| 127 |
+
if not sample:
|
| 128 |
+
seq_scores.append(0.0)
|
| 129 |
+
continue
|
| 130 |
+
seq_scores.append(
|
| 131 |
+
float(
|
| 132 |
+
np.array(
|
| 133 |
+
[
|
| 134 |
+
SEPTargetApproximator._primary_logprob(token)
|
| 135 |
+
for token in sample
|
| 136 |
+
]
|
| 137 |
+
).sum()
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
result.append(seq_scores)
|
| 141 |
+
return result
|
| 142 |
+
|
| 143 |
+
@staticmethod
|
| 144 |
+
def _call_stat_calculator(calculator, *args):
|
| 145 |
+
signature = inspect.signature(calculator.__call__)
|
| 146 |
+
if any(
|
| 147 |
+
param.kind == inspect.Parameter.VAR_POSITIONAL
|
| 148 |
+
for param in signature.parameters.values()
|
| 149 |
+
):
|
| 150 |
+
return calculator(*args)
|
| 151 |
+
|
| 152 |
+
positional_count = len(
|
| 153 |
+
[
|
| 154 |
+
param
|
| 155 |
+
for param in signature.parameters.values()
|
| 156 |
+
if param.kind
|
| 157 |
+
in (
|
| 158 |
+
inspect.Parameter.POSITIONAL_ONLY,
|
| 159 |
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
| 160 |
+
)
|
| 161 |
+
]
|
| 162 |
+
)
|
| 163 |
+
return calculator(*args[:positional_count])
|
| 164 |
+
|
| 165 |
+
def _compute_semantic_entropy(
|
| 166 |
+
self, samples: List[List[Dict]], labels: Optional[List[int]] = None
|
| 167 |
+
) -> List[int]:
|
| 168 |
+
from lm_polygraph.estimators import SemanticEntropy
|
| 169 |
+
from lm_polygraph.stat_calculators import (
|
| 170 |
+
SemanticClassesCalculator,
|
| 171 |
+
SemanticMatrixCalculator,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
responses, logits = self._sample_with_logprobs(samples)
|
| 175 |
+
sample_texts, logprob_sums = self._normalize_sample_outputs(
|
| 176 |
+
responses,
|
| 177 |
+
logits,
|
| 178 |
+
sample_count=len(samples),
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
new_stats = {
|
| 182 |
+
'sample_texts': sample_texts,
|
| 183 |
+
'sample_log_probs': logprob_sums,
|
| 184 |
+
}
|
| 185 |
+
self.stats.update(new_stats)
|
| 186 |
+
|
| 187 |
+
semantic_matrix_calculator = SemanticMatrixCalculator(nli_model=self.nli_model)
|
| 188 |
+
semantic_matrix = self._call_stat_calculator(
|
| 189 |
+
semantic_matrix_calculator,
|
| 190 |
+
self.stats,
|
| 191 |
+
samples,
|
| 192 |
+
self.extractor,
|
| 193 |
+
)
|
| 194 |
+
self.stats.update(semantic_matrix)
|
| 195 |
+
|
| 196 |
+
semantic_classes_calculator = SemanticClassesCalculator()
|
| 197 |
+
semantic_classes = self._call_stat_calculator(
|
| 198 |
+
semantic_classes_calculator,
|
| 199 |
+
self.stats,
|
| 200 |
+
samples,
|
| 201 |
+
self.extractor,
|
| 202 |
+
)
|
| 203 |
+
self.stats.update(semantic_classes)
|
| 204 |
+
|
| 205 |
+
semantic_entropy_calculator = SemanticEntropy()
|
| 206 |
+
semantic_entropy = semantic_entropy_calculator(self.stats)
|
| 207 |
+
|
| 208 |
+
new_labels, split = binarize_entropy(torch.tensor(semantic_entropy))
|
| 209 |
+
self.split = split
|
| 210 |
+
|
| 211 |
+
if labels is not None:
|
| 212 |
+
_log_roc_auc(labels, semantic_entropy)
|
| 213 |
+
|
| 214 |
+
return new_labels.tolist()
|
| 215 |
+
|
| 216 |
+
def fit(
|
| 217 |
+
self, samples: List[List[Dict]], labels: Optional[List[int]] = None
|
| 218 |
+
) -> List[int]:
|
| 219 |
+
return self._compute_semantic_entropy(samples, labels)
|
| 220 |
+
|
| 221 |
+
def __call__(
|
| 222 |
+
self, samples: List[List[Dict]], labels: Optional[List[int]] = None
|
| 223 |
+
) -> List[int]:
|
| 224 |
+
return self._compute_semantic_entropy(samples, labels)
|
sirin/detection/base.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import datasets
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import torch.multiprocessing as mp
|
| 10 |
+
import transformers
|
| 11 |
+
from loguru import logger as lg
|
| 12 |
+
|
| 13 |
+
from sirin.definitions import (
|
| 14 |
+
DSET_KEY,
|
| 15 |
+
INPUT_COL,
|
| 16 |
+
INPUT_PROC_COL,
|
| 17 |
+
REFERENCE_COL,
|
| 18 |
+
TARGET_COL,
|
| 19 |
+
GROUP_ID_COL,
|
| 20 |
+
DetectionTaskType,
|
| 21 |
+
LmMetric,
|
| 22 |
+
)
|
| 23 |
+
from sirin.detection.approximators import TargetApproximatorBase
|
| 24 |
+
from sirin.detection.utils.torch import InputsDataset
|
| 25 |
+
from sirin.utils.hf import get_dataset_identifier
|
| 26 |
+
from sirin.inference.adapters import ModelAdapterBase
|
| 27 |
+
from sirin.inference.model_manager import ModelManager
|
| 28 |
+
from sirin.inference.cleaners import (
|
| 29 |
+
BaseCleaner,
|
| 30 |
+
CodeCleaner,
|
| 31 |
+
CustomCleaner,
|
| 32 |
+
HTMLCleaner,
|
| 33 |
+
RegexCleaner,
|
| 34 |
+
)
|
| 35 |
+
from sirin.detection.splitters import SplitManager
|
| 36 |
+
from sirin.detection.utils.logits import logits_to_probs_preds
|
| 37 |
+
from sirin.loggers import LoggerBase
|
| 38 |
+
from sirin.metrics import calculate_lm_metrics
|
| 39 |
+
from sirin.models.detection import (
|
| 40 |
+
BertScoreConfig,
|
| 41 |
+
DetectionResult,
|
| 42 |
+
DetectorBaseConfig,
|
| 43 |
+
PipelineBaseConfig,
|
| 44 |
+
TrainingArgsConfig,
|
| 45 |
+
)
|
| 46 |
+
from sirin.models.inference import CleanerConfig
|
| 47 |
+
from sirin.utils.savers import DatasetSaver
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class DetectorBase(ABC):
|
| 51 |
+
"""Abstract base class for different detection methods."""
|
| 52 |
+
|
| 53 |
+
model: Any
|
| 54 |
+
|
| 55 |
+
def __init__(
|
| 56 |
+
self,
|
| 57 |
+
config: DetectorBaseConfig,
|
| 58 |
+
):
|
| 59 |
+
self.threshold = 0.5
|
| 60 |
+
self.config = config
|
| 61 |
+
self.device = self.config.device
|
| 62 |
+
self.num_cpus = max(1, min(self.config.num_cpus, mp.cpu_count() - 2))
|
| 63 |
+
self.set_all_seeds(self.config.seed)
|
| 64 |
+
self._context_splitter = (
|
| 65 |
+
SplitManager(
|
| 66 |
+
config=config.context_split_config,
|
| 67 |
+
split_response=getattr(
|
| 68 |
+
config.context_split_config, 'split_response', False
|
| 69 |
+
),
|
| 70 |
+
)
|
| 71 |
+
if config.context_split_config is not None
|
| 72 |
+
else None
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
@classmethod
|
| 76 |
+
def set_all_seeds(cls, seed: int):
|
| 77 |
+
random.seed(seed)
|
| 78 |
+
np.random.seed(seed)
|
| 79 |
+
torch.manual_seed(seed)
|
| 80 |
+
torch.cuda.manual_seed(seed)
|
| 81 |
+
torch.cuda.manual_seed_all(seed) # for multi-GPU
|
| 82 |
+
torch.backends.cudnn.deterministic = True
|
| 83 |
+
torch.backends.cudnn.benchmark = False
|
| 84 |
+
transformers.set_seed(seed)
|
| 85 |
+
|
| 86 |
+
@abstractmethod
|
| 87 |
+
def setup_model(self, model: Optional[ModelAdapterBase] = None):
|
| 88 |
+
pass
|
| 89 |
+
|
| 90 |
+
@abstractmethod
|
| 91 |
+
def detect(self, sample: Any, **kwargs) -> Dict[str, Any]:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
def _validate_and_create_save_dir(self, filepath: Optional[str]) -> Path:
|
| 95 |
+
if filepath is None:
|
| 96 |
+
raise ValueError("Filepath must be provided for saving.")
|
| 97 |
+
|
| 98 |
+
save_dir = Path(filepath)
|
| 99 |
+
|
| 100 |
+
# Ensure we're saving to a directory, not a file
|
| 101 |
+
if save_dir.suffix:
|
| 102 |
+
raise ValueError(
|
| 103 |
+
"Please provide a directory path (folder name) for saving, "
|
| 104 |
+
"not a file path with extension."
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
return save_dir
|
| 109 |
+
|
| 110 |
+
def _split_context_samples(
|
| 111 |
+
self, samples: List[Any]
|
| 112 |
+
) -> Tuple[List[Any], Optional[List[int]]]:
|
| 113 |
+
if not self._context_splitter:
|
| 114 |
+
return samples, None
|
| 115 |
+
|
| 116 |
+
split_samples = self._context_splitter.split_inputs(samples)
|
| 117 |
+
group_ids = []
|
| 118 |
+
flattened_samples = []
|
| 119 |
+
for index, splits in enumerate(split_samples):
|
| 120 |
+
group_ids.extend([index] * len(splits))
|
| 121 |
+
flattened_samples.extend(splits)
|
| 122 |
+
|
| 123 |
+
return flattened_samples, group_ids
|
| 124 |
+
|
| 125 |
+
def _aggregate_context_predictions(
|
| 126 |
+
self,
|
| 127 |
+
group_ids: Optional[List[int]],
|
| 128 |
+
preds: Any,
|
| 129 |
+
probs: Any,
|
| 130 |
+
binary: bool = True,
|
| 131 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 132 |
+
if not self._context_splitter or group_ids is None:
|
| 133 |
+
return preds, probs
|
| 134 |
+
|
| 135 |
+
return self._context_splitter.aggregate_predictions(
|
| 136 |
+
group_ids, preds, probs, binary=binary
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def _logits_to_probs_preds(
|
| 140 |
+
self, logits: torch.Tensor, threshold: Optional[float] = None
|
| 141 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 142 |
+
threshold = self.threshold if threshold is None else threshold
|
| 143 |
+
return logits_to_probs_preds(logits, threshold)
|
| 144 |
+
|
| 145 |
+
@abstractmethod
|
| 146 |
+
def _save_config(self, save_dir: Path) -> None:
|
| 147 |
+
pass
|
| 148 |
+
|
| 149 |
+
@abstractmethod
|
| 150 |
+
def _load_config(self, load_dir: Path) -> None:
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
+
def save(self, filepath: Optional[str] = None):
|
| 154 |
+
# Use model_save_path from config if filepath not provided
|
| 155 |
+
if filepath is None:
|
| 156 |
+
filepath = getattr(self.config, 'model_save_path', None)
|
| 157 |
+
|
| 158 |
+
save_dir = self._validate_and_create_save_dir(filepath)
|
| 159 |
+
|
| 160 |
+
# Save config (required, implemented by subclass)
|
| 161 |
+
self._save_config(save_dir)
|
| 162 |
+
|
| 163 |
+
# Save additional components if implemented
|
| 164 |
+
if hasattr(self, '_save_additional_components'):
|
| 165 |
+
self._save_additional_components(save_dir)
|
| 166 |
+
|
| 167 |
+
lg.info(f"Successfully saved to {save_dir}")
|
| 168 |
+
|
| 169 |
+
def load(self, filepath: str):
|
| 170 |
+
load_dir = Path(filepath)
|
| 171 |
+
|
| 172 |
+
if not load_dir.exists():
|
| 173 |
+
raise FileNotFoundError(f"Load directory not found: {load_dir}")
|
| 174 |
+
|
| 175 |
+
if not load_dir.is_dir():
|
| 176 |
+
raise ValueError(f"Expected directory path, got file: {load_dir}")
|
| 177 |
+
|
| 178 |
+
# Load config (required, implemented by subclass)
|
| 179 |
+
self._load_config(load_dir)
|
| 180 |
+
|
| 181 |
+
# Load additional components if implemented
|
| 182 |
+
if hasattr(self, '_load_additional_components'):
|
| 183 |
+
self._load_additional_components(load_dir)
|
| 184 |
+
|
| 185 |
+
lg.info(f"Successfully loaded from {load_dir}")
|
| 186 |
+
|
| 187 |
+
@abstractmethod
|
| 188 |
+
def train(
|
| 189 |
+
self,
|
| 190 |
+
cfg: TrainingArgsConfig,
|
| 191 |
+
train_data: InputsDataset,
|
| 192 |
+
val_data: Optional[InputsDataset],
|
| 193 |
+
logger: Any = None,
|
| 194 |
+
) -> DetectionResult:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class PipelineBase(ABC):
|
| 199 |
+
"""Abstract base class for different trainers of detectors."""
|
| 200 |
+
|
| 201 |
+
def __init__(
|
| 202 |
+
self,
|
| 203 |
+
config: PipelineBaseConfig,
|
| 204 |
+
detector: DetectorBase,
|
| 205 |
+
train_dataset: datasets.Dataset,
|
| 206 |
+
target_col: str = TARGET_COL,
|
| 207 |
+
eval_dataset: Optional[datasets.Dataset] = None,
|
| 208 |
+
task_type: str = DetectionTaskType.HALLUCINATION_DETECTION,
|
| 209 |
+
generator_adapter: Optional[ModelAdapterBase] = None,
|
| 210 |
+
target_approximator: Optional[TargetApproximatorBase] = None,
|
| 211 |
+
experiment_logger: Optional[LoggerBase] = None,
|
| 212 |
+
):
|
| 213 |
+
self.config = config
|
| 214 |
+
self.detector = detector
|
| 215 |
+
self.train_dataset = train_dataset
|
| 216 |
+
self.eval_dataset = eval_dataset
|
| 217 |
+
self.task_type = task_type
|
| 218 |
+
self.target_col = target_col
|
| 219 |
+
|
| 220 |
+
self._generator_adapter = generator_adapter
|
| 221 |
+
self.target_approximator = target_approximator
|
| 222 |
+
self.experiment_logger = experiment_logger
|
| 223 |
+
|
| 224 |
+
@abstractmethod
|
| 225 |
+
def train(self) -> DetectionResult:
|
| 226 |
+
pass
|
| 227 |
+
|
| 228 |
+
@abstractmethod
|
| 229 |
+
def eval(self) -> DetectionResult:
|
| 230 |
+
pass
|
| 231 |
+
|
| 232 |
+
def _load_dataset(
|
| 233 |
+
self,
|
| 234 |
+
dataset: datasets.Dataset | datasets.DatasetDict,
|
| 235 |
+
split: Optional[str] = None,
|
| 236 |
+
) -> Tuple[InputsDataset, DatasetSaver]:
|
| 237 |
+
"""Load and process dataset through the pipeline."""
|
| 238 |
+
split_name = split or ''
|
| 239 |
+
dataset_name = get_dataset_identifier(dataset)
|
| 240 |
+
if split and hasattr(dataset, 'keys') and split in dataset:
|
| 241 |
+
dataset = dataset[split]
|
| 242 |
+
if self._generator_adapter and self._generator_adapter.name:
|
| 243 |
+
model_name = self._generator_adapter.name.split('/')[-1].replace('.', '')
|
| 244 |
+
else:
|
| 245 |
+
model_name = ''
|
| 246 |
+
|
| 247 |
+
assert self.target_col in dataset.column_names, (
|
| 248 |
+
f"Target column '{self.target_col}' not found in dataset."
|
| 249 |
+
)
|
| 250 |
+
assert INPUT_COL in dataset.column_names, (
|
| 251 |
+
f"Input column '{INPUT_COL}' not found in dataset."
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
dataset_saver = (
|
| 255 |
+
DatasetSaver(
|
| 256 |
+
dataset_name,
|
| 257 |
+
save_dir=self.config.save_dir,
|
| 258 |
+
file_type_alias=DSET_KEY,
|
| 259 |
+
)
|
| 260 |
+
if self.config.save_intermediate
|
| 261 |
+
else None
|
| 262 |
+
)
|
| 263 |
+
stages = [
|
| 264 |
+
('generation', self._generate_answers, {}),
|
| 265 |
+
('cleaning', self._clean_generation_outputs, {}),
|
| 266 |
+
('metrics', self._compute_lm_metrics, {}),
|
| 267 |
+
]
|
| 268 |
+
|
| 269 |
+
if split == 'train' and self.config.split_context_train:
|
| 270 |
+
stages.append(('splitting', self._split_context, {}))
|
| 271 |
+
|
| 272 |
+
for stage_name, processor, kwargs in stages:
|
| 273 |
+
lg.info(f"Starting stage: {stage_name}")
|
| 274 |
+
dataset = processor(dataset, **kwargs)
|
| 275 |
+
|
| 276 |
+
if dataset_saver is not None:
|
| 277 |
+
dataset_saver.save(
|
| 278 |
+
dataset,
|
| 279 |
+
label='/'.join([model_name, stage_name, split_name]).strip('/'),
|
| 280 |
+
description=f"Dataset {dataset_name} (split={split_name}, model={model_name}) after {stage_name} stage.",
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
dataset = InputsDataset(
|
| 284 |
+
dataset[INPUT_COL],
|
| 285 |
+
dataset[self.target_col],
|
| 286 |
+
dataset[GROUP_ID_COL] if GROUP_ID_COL in dataset.column_names else None,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
return dataset, dataset_saver
|
| 290 |
+
|
| 291 |
+
def _split_context(self, dataset: datasets.Dataset) -> datasets.Dataset:
|
| 292 |
+
if self.detector._context_splitter:
|
| 293 |
+
return self.detector._context_splitter.split_dataset(dataset)
|
| 294 |
+
lg.info("No context splitting specified.")
|
| 295 |
+
return dataset
|
| 296 |
+
|
| 297 |
+
def _load_lm_metrics(self) -> Dict[LmMetric, Any]:
|
| 298 |
+
"""Extract metrics to calculate from config with their configurations."""
|
| 299 |
+
metrics = self.config.lm_metrics
|
| 300 |
+
metrics_config = {}
|
| 301 |
+
|
| 302 |
+
if metrics.rouge:
|
| 303 |
+
for metric in LmMetric.get_rouge_metrics():
|
| 304 |
+
metrics_config[metric] = None
|
| 305 |
+
|
| 306 |
+
if metrics.bleu:
|
| 307 |
+
metrics_config[LmMetric.BLEU] = None
|
| 308 |
+
|
| 309 |
+
if metrics.meteor:
|
| 310 |
+
metrics_config[LmMetric.METEOR] = None
|
| 311 |
+
|
| 312 |
+
if metrics.ter:
|
| 313 |
+
metrics_config[LmMetric.TER] = None
|
| 314 |
+
|
| 315 |
+
if metrics.bert_score:
|
| 316 |
+
bert_config = BertScoreConfig(
|
| 317 |
+
model_path=metrics.bert_score.model_path,
|
| 318 |
+
batch_size=metrics.bert_score.batch_size,
|
| 319 |
+
nthreads=metrics.bert_score.nthreads,
|
| 320 |
+
device=metrics.bert_score.device
|
| 321 |
+
if hasattr(metrics.bert_score, 'device')
|
| 322 |
+
else None,
|
| 323 |
+
lang=metrics.bert_score.lang
|
| 324 |
+
if hasattr(metrics.bert_score, 'lang')
|
| 325 |
+
else 'en',
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
for metric in LmMetric.get_bert_metrics():
|
| 329 |
+
metrics_config[metric] = bert_config
|
| 330 |
+
|
| 331 |
+
return metrics_config
|
| 332 |
+
|
| 333 |
+
def _generate_answers(
|
| 334 |
+
self,
|
| 335 |
+
dataset: datasets.Dataset,
|
| 336 |
+
) -> datasets.Dataset:
|
| 337 |
+
if self.task_type == DetectionTaskType.QUERY_ANSWERABILITY:
|
| 338 |
+
return dataset
|
| 339 |
+
|
| 340 |
+
generation_idxs = []
|
| 341 |
+
generation_inputs = []
|
| 342 |
+
for idx, example in enumerate(dataset):
|
| 343 |
+
input_val = example[INPUT_COL]
|
| 344 |
+
if input_val and input_val[-1]['role'] != 'assistant':
|
| 345 |
+
generation_idxs.append(idx)
|
| 346 |
+
generation_inputs.append(input_val)
|
| 347 |
+
|
| 348 |
+
if generation_idxs:
|
| 349 |
+
if self._generator_adapter is None:
|
| 350 |
+
raise ValueError(
|
| 351 |
+
"Dataset contains samples without assistant answers, "
|
| 352 |
+
"but no generator_adapter was provided to the pipeline."
|
| 353 |
+
)
|
| 354 |
+
generator_model = ModelManager.load_model(self._generator_adapter)
|
| 355 |
+
generated_answers = []
|
| 356 |
+
batch_size = self.detector.config.batch_size or 1
|
| 357 |
+
sampling = self.config.sampling
|
| 358 |
+
for i in range(0, len(generation_inputs), batch_size):
|
| 359 |
+
batch_inputs = generation_inputs[i : i + batch_size]
|
| 360 |
+
batch_answers = generator_model.sample(
|
| 361 |
+
batch_inputs,
|
| 362 |
+
max_tokens=sampling.max_length,
|
| 363 |
+
temperature=sampling.temperature,
|
| 364 |
+
top_p=sampling.top_p,
|
| 365 |
+
top_k=sampling.top_k,
|
| 366 |
+
**sampling.kwargs,
|
| 367 |
+
)
|
| 368 |
+
generated_answers.extend(batch_answers)
|
| 369 |
+
|
| 370 |
+
generated_dict = dict(zip(generation_idxs, generated_answers))
|
| 371 |
+
|
| 372 |
+
def add_generation(example, idx):
|
| 373 |
+
if idx in generated_dict:
|
| 374 |
+
example[INPUT_COL].append(
|
| 375 |
+
{
|
| 376 |
+
'role': 'assistant',
|
| 377 |
+
'content': generated_dict[idx],
|
| 378 |
+
}
|
| 379 |
+
)
|
| 380 |
+
return example
|
| 381 |
+
|
| 382 |
+
dataset = dataset.map(
|
| 383 |
+
add_generation,
|
| 384 |
+
with_indices=True,
|
| 385 |
+
load_from_cache_file=False,
|
| 386 |
+
desc="Updating with generated answers",
|
| 387 |
+
batched=False,
|
| 388 |
+
num_proc=self.detector.num_cpus
|
| 389 |
+
if self.detector.config.use_multiprocessing
|
| 390 |
+
else None,
|
| 391 |
+
)
|
| 392 |
+
else:
|
| 393 |
+
lg.info("There are no missing LLM answers. No need to generate answers.")
|
| 394 |
+
|
| 395 |
+
return dataset
|
| 396 |
+
|
| 397 |
+
def _compute_lm_metrics(
|
| 398 |
+
self,
|
| 399 |
+
dataset: datasets.Dataset,
|
| 400 |
+
) -> datasets.Dataset:
|
| 401 |
+
if self.config.lm_metrics is not None:
|
| 402 |
+
metrics_to_calculate = self._load_lm_metrics()
|
| 403 |
+
existing_columns = set(dataset.column_names)
|
| 404 |
+
|
| 405 |
+
missing_metrics = {}
|
| 406 |
+
for metric, config in metrics_to_calculate.items():
|
| 407 |
+
if metric.value not in existing_columns:
|
| 408 |
+
missing_metrics[metric] = config
|
| 409 |
+
|
| 410 |
+
if not missing_metrics:
|
| 411 |
+
lg.info("All requested metrics already present in dataset.")
|
| 412 |
+
return dataset
|
| 413 |
+
|
| 414 |
+
lg.info(
|
| 415 |
+
f"Calculating missing metrics: {[m.value for m in missing_metrics.keys()]}"
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# Prefer cleaned inputs if available.
|
| 419 |
+
source_col = (
|
| 420 |
+
INPUT_PROC_COL if INPUT_PROC_COL in dataset.column_names else INPUT_COL
|
| 421 |
+
)
|
| 422 |
+
generations = []
|
| 423 |
+
for item in dataset[source_col]:
|
| 424 |
+
if isinstance(item, list):
|
| 425 |
+
if not item:
|
| 426 |
+
generations.append('')
|
| 427 |
+
continue
|
| 428 |
+
last_item = item[-1]
|
| 429 |
+
if isinstance(last_item, dict):
|
| 430 |
+
generations.append(last_item.get('content', ''))
|
| 431 |
+
else:
|
| 432 |
+
generations.append(str(last_item))
|
| 433 |
+
elif isinstance(item, dict):
|
| 434 |
+
generations.append(item.get('content', ''))
|
| 435 |
+
else:
|
| 436 |
+
generations.append(item)
|
| 437 |
+
|
| 438 |
+
scores_dict = calculate_lm_metrics(
|
| 439 |
+
missing_metrics,
|
| 440 |
+
generations,
|
| 441 |
+
dataset[REFERENCE_COL],
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
for metric_name, metric_values in scores_dict.items():
|
| 445 |
+
dataset = dataset.add_column(metric_name, metric_values)
|
| 446 |
+
else:
|
| 447 |
+
lg.info("Metrics calculation is disabled.")
|
| 448 |
+
|
| 449 |
+
return dataset
|
| 450 |
+
|
| 451 |
+
def _clean_generation_outputs(
|
| 452 |
+
self,
|
| 453 |
+
dataset: datasets.Dataset,
|
| 454 |
+
) -> datasets.Dataset:
|
| 455 |
+
if self.config.cleaner_configs:
|
| 456 |
+
|
| 457 |
+
def clean_example(example: List[Dict[str, str]], cleaner):
|
| 458 |
+
example[-1]['content'] = cleaner.clean(example[-1]['content'])
|
| 459 |
+
return example
|
| 460 |
+
|
| 461 |
+
def _create_cleaner(config: CleanerConfig) -> BaseCleaner:
|
| 462 |
+
"""Factory method to create the appropriate cleaner based on config"""
|
| 463 |
+
cleaner_map = {
|
| 464 |
+
'regex': RegexCleaner,
|
| 465 |
+
'html': HTMLCleaner,
|
| 466 |
+
'code': CodeCleaner,
|
| 467 |
+
'custom': CustomCleaner,
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
cleaner_class = cleaner_map.get(config.type)
|
| 471 |
+
if not cleaner_class:
|
| 472 |
+
raise ValueError(f"Unknown cleaner type: {config.type}")
|
| 473 |
+
|
| 474 |
+
return cleaner_class(config.params or {})
|
| 475 |
+
|
| 476 |
+
for cleaner_config in self.config.cleaner_configs:
|
| 477 |
+
cleaner = _create_cleaner(config=cleaner_config)
|
| 478 |
+
dataset = dataset.map(
|
| 479 |
+
lambda x: {INPUT_PROC_COL: clean_example(x[INPUT_COL], cleaner)},
|
| 480 |
+
batched=False,
|
| 481 |
+
num_proc=self.detector.num_cpus
|
| 482 |
+
if self.detector.config.use_multiprocessing
|
| 483 |
+
else None,
|
| 484 |
+
desc=f"Using cleaner: {cleaner_config.name}",
|
| 485 |
+
)
|
| 486 |
+
else:
|
| 487 |
+
lg.info("No cleaning specified.")
|
| 488 |
+
dataset = dataset.add_column(INPUT_PROC_COL, dataset[INPUT_COL])
|
| 489 |
+
|
| 490 |
+
return dataset
|
| 491 |
+
|
| 492 |
+
def __del__(self):
|
| 493 |
+
if self.experiment_logger:
|
| 494 |
+
try:
|
| 495 |
+
self.experiment_logger.finish()
|
| 496 |
+
except Exception as e:
|
| 497 |
+
lg.debug(f"Experiment logger finish failed during pipeline cleanup: {e}")
|
sirin/detection/judging/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def _ensure_transformers_hybrid_cache(transformers_module=None):
|
| 2 |
+
previous_module = None
|
| 3 |
+
if transformers_module is None:
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
previous_module = sys.modules.get('transformers')
|
| 7 |
+
from transformers import AutoProcessor # noqa: F401
|
| 8 |
+
transformers_module = sys.modules['transformers']
|
| 9 |
+
|
| 10 |
+
if hasattr(transformers_module, 'HybridCache'):
|
| 11 |
+
return
|
| 12 |
+
|
| 13 |
+
dynamic_cache = getattr(transformers_module, 'DynamicCache', None)
|
| 14 |
+
if dynamic_cache is None:
|
| 15 |
+
try:
|
| 16 |
+
from transformers import DynamicCache as dynamic_cache
|
| 17 |
+
except ImportError:
|
| 18 |
+
return
|
| 19 |
+
transformers_module.HybridCache = dynamic_cache
|
| 20 |
+
if previous_module is not None and previous_module is not transformers_module:
|
| 21 |
+
previous_module.HybridCache = dynamic_cache
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
_ensure_transformers_hybrid_cache()
|
| 25 |
+
|
| 26 |
+
from .judges import (
|
| 27 |
+
SequenceEncoderJudge,
|
| 28 |
+
SequenceDecoderJudge,
|
| 29 |
+
SequenceOpenAIJudge,
|
| 30 |
+
TokenDecoderJudge,
|
| 31 |
+
TokenEncoderJudge,
|
| 32 |
+
TokenOpenAIJudge,
|
| 33 |
+
ClaimDecoderJudge,
|
| 34 |
+
ClaimEncoderJudge,
|
| 35 |
+
ClaimOpenAIJudge,
|
| 36 |
+
HfJudgeBase,
|
| 37 |
+
JudgeAnnotationError,
|
| 38 |
+
OpenAIJudgeBase,
|
| 39 |
+
)
|
| 40 |
+
from .pipeline import JudgePipeline
|
sirin/detection/judging/judges/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .token import TokenDecoderJudge, TokenEncoderJudge, TokenOpenAIJudge
|
| 2 |
+
from .sequence import SequenceDecoderJudge, SequenceEncoderJudge, SequenceOpenAIJudge
|
| 3 |
+
from .claim import ClaimDecoderJudge, ClaimEncoderJudge, ClaimOpenAIJudge
|
| 4 |
+
from .base import HfJudgeBase, JudgeAnnotationError, OpenAIJudgeBase
|
sirin/detection/judging/judges/base.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import joblib
|
| 6 |
+
from datasets import Dataset
|
| 7 |
+
from loguru import logger as lg
|
| 8 |
+
from peft import get_peft_model
|
| 9 |
+
from transformers import (
|
| 10 |
+
DataCollatorForTokenClassification,
|
| 11 |
+
DataCollatorWithPadding,
|
| 12 |
+
TrainingArguments,
|
| 13 |
+
)
|
| 14 |
+
from sirin.utils.config_manager import validate_hydra_config
|
| 15 |
+
|
| 16 |
+
from sirin.models.detection import DetectionResult, JudgeBaseConfig
|
| 17 |
+
from sirin.utils.config_serialization import (
|
| 18 |
+
deserialize_judge_config,
|
| 19 |
+
serialize_judge_config,
|
| 20 |
+
)
|
| 21 |
+
from sirin.inference.adapters import ModelAdapterBase, HfModelAdapter, OpenAIModelAdapter
|
| 22 |
+
from sirin.inference.model_manager import ModelManager
|
| 23 |
+
from sirin.detection.base import DetectorBase
|
| 24 |
+
from sirin.definitions import (
|
| 25 |
+
INPUT_COL,
|
| 26 |
+
TARGET_COL,
|
| 27 |
+
ClassificationMetric,
|
| 28 |
+
BASIC_METRICS,
|
| 29 |
+
DetectionLevel,
|
| 30 |
+
DataCollatorType,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class JudgeAnnotationError(Exception):
|
| 35 |
+
"""Raised when a judge cannot produce a usable annotation for a sample.
|
| 36 |
+
|
| 37 |
+
E.g. every sampled generation failed the reference-echo check, so there is no honest
|
| 38 |
+
per-character consensus to emit. Callers must surface this rather than let an empty
|
| 39 |
+
annotation render as "all clear".
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class JudgeBase(DetectorBase):
|
| 44 |
+
"""Base class for all judge implementations with shared configuration logic."""
|
| 45 |
+
detection_level: DetectionLevel
|
| 46 |
+
|
| 47 |
+
@validate_hydra_config
|
| 48 |
+
def __init__(self, config: JudgeBaseConfig, model_adapter: ModelAdapterBase):
|
| 49 |
+
super().__init__(config)
|
| 50 |
+
self.model_adapter = ModelManager.load_model(model_adapter)
|
| 51 |
+
if self.config.model_load_path:
|
| 52 |
+
self.load(self.config.model_load_path)
|
| 53 |
+
self.setup_model()
|
| 54 |
+
|
| 55 |
+
@abstractmethod
|
| 56 |
+
def setup_model(self):
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
@abstractmethod
|
| 60 |
+
def _compute_metrics(self, eval_pred: Any) -> Dict[str, Any]:
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
@abstractmethod
|
| 64 |
+
def _preprocess(self, samples: Dataset) -> Dict[str, Any]:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
def _save_config(self, save_dir: Path):
|
| 68 |
+
"""
|
| 69 |
+
Save judge configuration to directory.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
save_dir: Directory where to save the config
|
| 73 |
+
"""
|
| 74 |
+
model_path = str(save_dir / 'model') if hasattr(self, '_save_model') else None
|
| 75 |
+
config_dict = serialize_judge_config(
|
| 76 |
+
config=self.config,
|
| 77 |
+
threshold=self.threshold,
|
| 78 |
+
model_adapter=self.model_adapter.config,
|
| 79 |
+
model_path=model_path,
|
| 80 |
+
)
|
| 81 |
+
joblib.dump(config_dict, save_dir / 'config.joblib')
|
| 82 |
+
lg.info(f'Saved config to {save_dir / "config.joblib"}')
|
| 83 |
+
|
| 84 |
+
def _load_config(self, load_dir: Path):
|
| 85 |
+
config_path = load_dir / 'config.joblib'
|
| 86 |
+
if not config_path.exists():
|
| 87 |
+
raise FileNotFoundError(f'Config file not found at: {config_path}')
|
| 88 |
+
|
| 89 |
+
loaded_objects = joblib.load(config_path)
|
| 90 |
+
config, threshold, adapter_config, model_path = deserialize_judge_config(loaded_objects)
|
| 91 |
+
|
| 92 |
+
# Update config attributes
|
| 93 |
+
for key, value in config.__dict__.items():
|
| 94 |
+
if key not in ['model_load_path', 'model_save_path']:
|
| 95 |
+
setattr(self.config, key, value)
|
| 96 |
+
|
| 97 |
+
self.threshold = threshold
|
| 98 |
+
lg.info(f'Loaded config from {config_path}')
|
| 99 |
+
|
| 100 |
+
# Store adapter config for subclass use
|
| 101 |
+
self._loaded_adapter_config = adapter_config
|
| 102 |
+
self._loaded_model_path = model_path
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class HfJudgeBase(JudgeBase):
|
| 106 |
+
"""Base class for HuggingFace model-based judges with training support."""
|
| 107 |
+
|
| 108 |
+
# Override in subclasses to specify data collator type
|
| 109 |
+
data_collator_type: DataCollatorType = DataCollatorType.PADDING # Default for sequence encoder judges
|
| 110 |
+
|
| 111 |
+
def __init__(self, config: JudgeBaseConfig, model_adapter: ModelAdapterBase):
|
| 112 |
+
super().__init__(config, model_adapter)
|
| 113 |
+
|
| 114 |
+
def setup_model(self):
|
| 115 |
+
if self.config.peft_config is not None:
|
| 116 |
+
self.model_adapter.model = get_peft_model(self.model_adapter.model, self.config.peft_config)
|
| 117 |
+
lg.info(self.model_adapter.model.print_trainable_parameters())
|
| 118 |
+
else:
|
| 119 |
+
lg.info("Training without peft")
|
| 120 |
+
|
| 121 |
+
self.class_token_ids = [
|
| 122 |
+
self.model_adapter.tokenizer.convert_tokens_to_ids(str(i))
|
| 123 |
+
for i in range(self.config.num_classification_heads)
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
def _save_model(self, save_dir: Path):
|
| 127 |
+
model_path = save_dir / 'model'
|
| 128 |
+
model_path.mkdir(exist_ok=True)
|
| 129 |
+
|
| 130 |
+
# Save model
|
| 131 |
+
if hasattr(self.model_adapter, 'model'):
|
| 132 |
+
self.model_adapter.model.save_pretrained(str(model_path))
|
| 133 |
+
lg.info(f'Saved model to {model_path}')
|
| 134 |
+
|
| 135 |
+
# Save tokenizer
|
| 136 |
+
if hasattr(self.model_adapter, 'tokenizer'):
|
| 137 |
+
self.model_adapter.tokenizer.save_pretrained(str(model_path))
|
| 138 |
+
lg.info(f'Saved tokenizer to {model_path}')
|
| 139 |
+
|
| 140 |
+
def _load_model(self, load_dir: Path):
|
| 141 |
+
model_path = load_dir / 'model'
|
| 142 |
+
|
| 143 |
+
if not model_path.exists():
|
| 144 |
+
raise FileNotFoundError(f'Model directory not found at: {model_path}')
|
| 145 |
+
|
| 146 |
+
# Load model using adapter config from _load_config
|
| 147 |
+
adapter_config = getattr(self, '_loaded_adapter_config', None)
|
| 148 |
+
if adapter_config is None:
|
| 149 |
+
raise ValueError('Config must be loaded before model. Call _load_config() first.')
|
| 150 |
+
|
| 151 |
+
# Update model path to point to saved model
|
| 152 |
+
adapter_config.model_path = str(model_path)
|
| 153 |
+
|
| 154 |
+
model_manager = ModelManager()
|
| 155 |
+
self.model_adapter = model_manager.load(
|
| 156 |
+
HfModelAdapter(config=adapter_config, model=str(model_path))
|
| 157 |
+
)
|
| 158 |
+
lg.info(f'Loaded model from {model_path}')
|
| 159 |
+
|
| 160 |
+
def _save_additional_components(self, save_dir: Path):
|
| 161 |
+
self._save_model(save_dir)
|
| 162 |
+
|
| 163 |
+
def _load_additional_components(self, load_dir: Path):
|
| 164 |
+
self._load_model(load_dir)
|
| 165 |
+
|
| 166 |
+
def _check_truncation_warning(self, samples: List[Dict[str, Any]]):
|
| 167 |
+
if not self.model_adapter.tokenizer:
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
preprocessed_samples = self.model_adapter._preprocess_input(samples)
|
| 171 |
+
|
| 172 |
+
if self.model_adapter.config.truncation:
|
| 173 |
+
for i, sample in enumerate(preprocessed_samples):
|
| 174 |
+
tokens = self.model_adapter.tokenizer.encode(
|
| 175 |
+
sample,
|
| 176 |
+
add_special_tokens=True,
|
| 177 |
+
truncation=False,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
max_length = self.model_adapter.tokenizer.model_max_length
|
| 181 |
+
if len(tokens) > max_length:
|
| 182 |
+
lg.warning(
|
| 183 |
+
f"Sample {i} exceeds model's max length ({len(tokens)} > {max_length}). "
|
| 184 |
+
f"Truncation will be applied. Consider reducing input length."
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
def train(
|
| 188 |
+
self,
|
| 189 |
+
training_args: TrainingArguments,
|
| 190 |
+
train_data: Dataset,
|
| 191 |
+
val_data: Optional[Dataset],
|
| 192 |
+
metrics: Optional[List[ClassificationMetric]] = None,
|
| 193 |
+
**kwargs,
|
| 194 |
+
) -> DetectionResult:
|
| 195 |
+
self.metrics_to_compute = metrics or BASIC_METRICS
|
| 196 |
+
|
| 197 |
+
train_data_tokenized = train_data.batch_process(
|
| 198 |
+
lambda x: self._preprocess(x),
|
| 199 |
+
batch_size=self.config.batch_size,
|
| 200 |
+
remove_columns=[INPUT_COL, TARGET_COL],
|
| 201 |
+
)
|
| 202 |
+
train_data_tokenized = Dataset.from_dict(train_data_tokenized)
|
| 203 |
+
|
| 204 |
+
val_data_tokenized = None
|
| 205 |
+
if val_data is not None:
|
| 206 |
+
val_data_tokenized = val_data.batch_process(
|
| 207 |
+
lambda x: self._preprocess(x),
|
| 208 |
+
batch_size=self.config.batch_size,
|
| 209 |
+
remove_columns=[INPUT_COL, TARGET_COL],
|
| 210 |
+
)
|
| 211 |
+
val_data_tokenized = Dataset.from_dict(val_data_tokenized)
|
| 212 |
+
|
| 213 |
+
# Select data collator based on judge type (specified by subclass)
|
| 214 |
+
if self.data_collator_type == DataCollatorType.TOKEN:
|
| 215 |
+
data_collator = DataCollatorForTokenClassification(
|
| 216 |
+
tokenizer=self.model_adapter.tokenizer,
|
| 217 |
+
padding=self.model_adapter.config.padding,
|
| 218 |
+
label_pad_token_id=-100,
|
| 219 |
+
)
|
| 220 |
+
elif self.data_collator_type == DataCollatorType.PADDING:
|
| 221 |
+
data_collator = DataCollatorWithPadding(
|
| 222 |
+
tokenizer=self.model_adapter.tokenizer,
|
| 223 |
+
padding=self.model_adapter.config.padding,
|
| 224 |
+
)
|
| 225 |
+
else:
|
| 226 |
+
raise ValueError(f'Invalid data_collator_type: {self.data_collator_type}')
|
| 227 |
+
|
| 228 |
+
trainer = self.trainer(
|
| 229 |
+
model=self.model_adapter.model,
|
| 230 |
+
data_collator=data_collator,
|
| 231 |
+
args=training_args,
|
| 232 |
+
train_dataset=train_data_tokenized,
|
| 233 |
+
eval_dataset=val_data_tokenized,
|
| 234 |
+
processing_class=self.model_adapter.tokenizer,
|
| 235 |
+
compute_metrics=self._compute_metrics if val_data_tokenized else None,
|
| 236 |
+
**kwargs,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
train_result = trainer.train()
|
| 240 |
+
self.model_adapter.model = trainer.model
|
| 241 |
+
|
| 242 |
+
if training_args.output_dir:
|
| 243 |
+
trainer.save_model()
|
| 244 |
+
self.model_adapter.tokenizer.save_pretrained(training_args.output_dir)
|
| 245 |
+
|
| 246 |
+
return DetectionResult(
|
| 247 |
+
metrics=train_result.metrics,
|
| 248 |
+
probs=None,
|
| 249 |
+
threshold=self.threshold,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class OpenAIJudgeBase(JudgeBase):
|
| 254 |
+
"""Base class for OpenAI API-based judges (no training support)."""
|
| 255 |
+
|
| 256 |
+
def __init__(self, config: JudgeBaseConfig, model_adapter: ModelAdapterBase):
|
| 257 |
+
super().__init__(config, model_adapter)
|
| 258 |
+
|
| 259 |
+
def setup_model(self):
|
| 260 |
+
pass
|
| 261 |
+
|
| 262 |
+
def _load_config(self, load_dir: Path):
|
| 263 |
+
"""Override to reload OpenAI model adapter after config load."""
|
| 264 |
+
super()._load_config(load_dir)
|
| 265 |
+
|
| 266 |
+
# Reload model adapter with saved config
|
| 267 |
+
model_manager = ModelManager()
|
| 268 |
+
self.model_adapter = model_manager.load(
|
| 269 |
+
OpenAIModelAdapter(
|
| 270 |
+
config=self._loaded_adapter_config,
|
| 271 |
+
model=self._loaded_adapter_config.model_path or self._loaded_model_path
|
| 272 |
+
)
|
| 273 |
+
)
|
| 274 |
+
lg.info('Reloaded OpenAI model adapter')
|
| 275 |
+
|
| 276 |
+
def train(
|
| 277 |
+
self,
|
| 278 |
+
training_args: TrainingArguments,
|
| 279 |
+
train_data: Dataset,
|
| 280 |
+
val_data: Optional[Dataset],
|
| 281 |
+
metrics: Optional[List[ClassificationMetric]] = None,
|
| 282 |
+
**kwargs,
|
| 283 |
+
) -> DetectionResult:
|
| 284 |
+
lg.info("There is no training for API judges")
|
| 285 |
+
return DetectionResult()
|
| 286 |
+
|
| 287 |
+
def _preprocess(self, samples: Dataset) -> Dataset:
|
| 288 |
+
return samples
|
| 289 |
+
|
| 290 |
+
def _compute_metrics(self, eval_pred: Any) -> Dict[str, Any]:
|
| 291 |
+
lg.info("There is no metrics compution during training for API judges")
|
| 292 |
+
return {}
|
sirin/detection/judging/judges/claim/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .decoder import ClaimDecoderJudge
|
| 2 |
+
from .encoder import ClaimEncoderJudge
|
| 3 |
+
from .openai import ClaimOpenAIJudge
|
sirin/detection/judging/judges/claim/decoder.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 3 |
+
|
| 4 |
+
import joblib
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from loguru import logger as lg
|
| 8 |
+
from transformers import EvalPrediction
|
| 9 |
+
|
| 10 |
+
from sirin.definitions import INPUT_COL, TARGET_COL, DetectionLevel, DataCollatorType
|
| 11 |
+
from sirin.detection.judging.judges.base import HfJudgeBase
|
| 12 |
+
from sirin.detection.judging.judges.utils import (
|
| 13 |
+
format_dialogue_for_training,
|
| 14 |
+
prepare_decoder_inputs_with_labels,
|
| 15 |
+
build_prompt_messages,
|
| 16 |
+
)
|
| 17 |
+
from sirin.detection.judging.training import SequenceDecoderJudgeTrainer
|
| 18 |
+
from sirin.detection.splitters import SplitManager
|
| 19 |
+
from sirin.detection.utils.basic import calibrate_threshold
|
| 20 |
+
from sirin.inference.adapters import HfModelAdapter, ModelAdapterBase
|
| 21 |
+
from sirin.metrics.classification import calculate_classification_metrics
|
| 22 |
+
from sirin.models.detection import HfJudgeConfig, SplitConfig
|
| 23 |
+
from sirin.utils.hf import get_assistant_prefix
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ClaimDecoderJudge(HfJudgeBase):
|
| 27 |
+
"""
|
| 28 |
+
Decoder judge implementation for claim-level hallucination detection.
|
| 29 |
+
|
| 30 |
+
Decomposes each response into atomic claims via SplitManager, classifies
|
| 31 |
+
each claim independently using next-token prediction from a generative
|
| 32 |
+
decoder, then re-aggregates per-claim predictions back to response level.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
detection_level = DetectionLevel.CLAIM
|
| 36 |
+
data_collator_type = DataCollatorType.TOKEN
|
| 37 |
+
trainer = SequenceDecoderJudgeTrainer
|
| 38 |
+
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
config: HfJudgeConfig,
|
| 42 |
+
model_adapter: HfModelAdapter,
|
| 43 |
+
response_splitter_config: SplitConfig,
|
| 44 |
+
split_model: Optional[ModelAdapterBase] = None,
|
| 45 |
+
):
|
| 46 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 47 |
+
self.response_splitter_config = response_splitter_config
|
| 48 |
+
self.response_splitter = SplitManager(config=response_splitter_config)
|
| 49 |
+
self.split_model = split_model or model_adapter
|
| 50 |
+
self.assistant_prefix = get_assistant_prefix(self.model_adapter._model_name)
|
| 51 |
+
|
| 52 |
+
def detect(
|
| 53 |
+
self,
|
| 54 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 55 |
+
labels: Optional[np.ndarray] = None,
|
| 56 |
+
**kwargs,
|
| 57 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 58 |
+
"""
|
| 59 |
+
Detects hallucinations at claim level using a generative decoder model.
|
| 60 |
+
|
| 61 |
+
Each response is split into atomic claims, then the decoder predicts
|
| 62 |
+
a class token (0, 1, etc.) for each claim independently. Per-claim
|
| 63 |
+
predictions are re-aggregated to response level.
|
| 64 |
+
"""
|
| 65 |
+
original_samples = list(samples)
|
| 66 |
+
|
| 67 |
+
samples_fact_splits = self.response_splitter.split_inputs(
|
| 68 |
+
samples, self.split_model
|
| 69 |
+
)
|
| 70 |
+
response_group_ids = []
|
| 71 |
+
for index, splits in enumerate(samples_fact_splits):
|
| 72 |
+
response_group_ids.extend([index] * len(splits))
|
| 73 |
+
samples = [split for splits in samples_fact_splits for split in splits]
|
| 74 |
+
|
| 75 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 76 |
+
|
| 77 |
+
self._check_truncation_warning(samples)
|
| 78 |
+
|
| 79 |
+
formatted_input = build_prompt_messages(self.config, samples)
|
| 80 |
+
|
| 81 |
+
preprocessed_input = self.model_adapter._preprocess_input(formatted_input)
|
| 82 |
+
preprocessed_input = [
|
| 83 |
+
f"{sample}{self.assistant_prefix}" for sample in preprocessed_input
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
inputs = self.model_adapter.tokenizer(
|
| 87 |
+
preprocessed_input,
|
| 88 |
+
return_tensors=self.config.return_tensors,
|
| 89 |
+
truncation=self.model_adapter.config.truncation,
|
| 90 |
+
padding=self.model_adapter.config.padding,
|
| 91 |
+
).to(self.config.device)
|
| 92 |
+
|
| 93 |
+
with torch.no_grad():
|
| 94 |
+
outputs = self.model_adapter.model.generate(
|
| 95 |
+
**inputs,
|
| 96 |
+
max_new_tokens=self.config.max_new_tokens,
|
| 97 |
+
do_sample=False,
|
| 98 |
+
temperature=self.config.temperature,
|
| 99 |
+
top_p=self.config.top_p,
|
| 100 |
+
pad_token_id=self.model_adapter.tokenizer.eos_token_id,
|
| 101 |
+
eos_token_id=self.model_adapter.tokenizer.eos_token_id,
|
| 102 |
+
return_dict_in_generate=True,
|
| 103 |
+
output_scores=True,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
first_token_logits = outputs.scores[0]
|
| 107 |
+
selected_logits = first_token_logits[:, self.class_token_ids]
|
| 108 |
+
probs, preds = self._logits_to_probs_preds(selected_logits)
|
| 109 |
+
|
| 110 |
+
probs = probs.tolist()
|
| 111 |
+
preds = preds.tolist()
|
| 112 |
+
|
| 113 |
+
preds, probs = self._aggregate_context_predictions(
|
| 114 |
+
group_ids, preds, probs, binary=(self.config.num_classification_heads <= 2)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
overall_preds, overall_probs = self.response_splitter.aggregate_predictions(
|
| 118 |
+
response_group_ids,
|
| 119 |
+
preds,
|
| 120 |
+
probs,
|
| 121 |
+
binary=(self.config.num_classification_heads <= 2),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
results = []
|
| 125 |
+
for sample_idx, sample in enumerate(original_samples):
|
| 126 |
+
sample_mask = [
|
| 127 |
+
i for i, gid in enumerate(response_group_ids) if gid == sample_idx
|
| 128 |
+
]
|
| 129 |
+
splitted_samples = samples_fact_splits[sample_idx]
|
| 130 |
+
sample_probs = [probs[i] for i in sample_mask]
|
| 131 |
+
sample_preds = [preds[i] for i in sample_mask]
|
| 132 |
+
facts = [
|
| 133 |
+
{"fact": split[1]["content"], "pred": pred, "prob": prob}
|
| 134 |
+
for split, pred, prob in zip(splitted_samples, sample_probs, sample_preds)
|
| 135 |
+
]
|
| 136 |
+
results.append(
|
| 137 |
+
{
|
| 138 |
+
"sample": sample,
|
| 139 |
+
"overall_pred": overall_preds[sample_idx],
|
| 140 |
+
"overall_prob": overall_probs[sample_idx],
|
| 141 |
+
"facts": facts,
|
| 142 |
+
}
|
| 143 |
+
)
|
| 144 |
+
self.claim_results = results
|
| 145 |
+
|
| 146 |
+
return overall_probs, overall_preds, labels
|
| 147 |
+
|
| 148 |
+
def _compute_metrics(self, eval_pred: EvalPrediction) -> Dict[str, Any]:
|
| 149 |
+
"""Compute metrics for claim-level classification."""
|
| 150 |
+
probs = eval_pred.predictions
|
| 151 |
+
labels = eval_pred.label_ids
|
| 152 |
+
|
| 153 |
+
if self.config.num_classification_heads == 2:
|
| 154 |
+
self.threshold = calibrate_threshold(
|
| 155 |
+
probs,
|
| 156 |
+
labels,
|
| 157 |
+
self.config.threshold_method,
|
| 158 |
+
self.config.threshold_percentile,
|
| 159 |
+
self.config.fixed_threshold,
|
| 160 |
+
)
|
| 161 |
+
preds = (probs > self.threshold).astype(int)
|
| 162 |
+
else:
|
| 163 |
+
self.threshold = None
|
| 164 |
+
preds = np.argmax(probs, axis=1)
|
| 165 |
+
|
| 166 |
+
metrics = calculate_classification_metrics(
|
| 167 |
+
labels,
|
| 168 |
+
probs,
|
| 169 |
+
preds,
|
| 170 |
+
metrics=self.metrics_to_compute,
|
| 171 |
+
)
|
| 172 |
+
lg.info(f"Evaluation metrics: {metrics}")
|
| 173 |
+
return metrics
|
| 174 |
+
|
| 175 |
+
def _preprocess(self, samples: Dict[str, Any]) -> Dict[str, Any]:
|
| 176 |
+
"""Preprocess samples for claim-level classification with decoder models."""
|
| 177 |
+
inputs = samples[INPUT_COL]
|
| 178 |
+
targets = samples[TARGET_COL]
|
| 179 |
+
|
| 180 |
+
self._check_truncation_warning(inputs)
|
| 181 |
+
|
| 182 |
+
formatted_messages = format_dialogue_for_training(
|
| 183 |
+
inputs, targets, self.config, is_token_level=False
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return prepare_decoder_inputs_with_labels(
|
| 187 |
+
formatted_messages, self.model_adapter, self.config
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
def _save_config(self, save_dir: Path):
|
| 191 |
+
super()._save_config(save_dir)
|
| 192 |
+
joblib.dump(self.response_splitter_config, save_dir / "splitter_config.joblib")
|
| 193 |
+
lg.info(f"Saved splitter config to {save_dir / 'splitter_config.joblib'}")
|
| 194 |
+
|
| 195 |
+
def _load_config(self, load_dir: Path):
|
| 196 |
+
super()._load_config(load_dir)
|
| 197 |
+
splitter_config_path = load_dir / "splitter_config.joblib"
|
| 198 |
+
if splitter_config_path.exists():
|
| 199 |
+
self.response_splitter_config = joblib.load(splitter_config_path)
|
| 200 |
+
self.response_splitter = SplitManager(config=self.response_splitter_config)
|
| 201 |
+
lg.info(f"Loaded splitter config from {splitter_config_path}")
|
sirin/detection/judging/judges/claim/encoder.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 3 |
+
|
| 4 |
+
import joblib
|
| 5 |
+
import numpy as np
|
| 6 |
+
from loguru import logger as lg
|
| 7 |
+
from transformers import EvalPrediction, Trainer
|
| 8 |
+
|
| 9 |
+
from sirin.definitions import INPUT_COL, TARGET_COL, DetectionLevel, DataCollatorType
|
| 10 |
+
from sirin.detection.judging.judges.base import HfJudgeBase
|
| 11 |
+
from sirin.detection.judging.judges.utils import (
|
| 12 |
+
process_logits_to_probs,
|
| 13 |
+
calibrate_and_compute_metrics,
|
| 14 |
+
)
|
| 15 |
+
from sirin.detection.splitters import SplitManager
|
| 16 |
+
from sirin.inference.adapters import HfModelAdapter, ModelAdapterBase
|
| 17 |
+
from sirin.models.detection import HfJudgeConfig, SplitConfig
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ClaimEncoderJudge(HfJudgeBase):
|
| 21 |
+
"""
|
| 22 |
+
Encoder judge implementation for claim-level hallucination detection.
|
| 23 |
+
|
| 24 |
+
Decomposes each response into atomic claims via SplitManager, classifies
|
| 25 |
+
each claim independently using an encoder classification head, then
|
| 26 |
+
re-aggregates per-claim predictions back to response level.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
detection_level = DetectionLevel.CLAIM
|
| 30 |
+
data_collator_type = DataCollatorType.PADDING
|
| 31 |
+
trainer = Trainer
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
config: HfJudgeConfig,
|
| 36 |
+
model_adapter: HfModelAdapter,
|
| 37 |
+
response_splitter_config: SplitConfig,
|
| 38 |
+
split_model: Optional[ModelAdapterBase] = None,
|
| 39 |
+
):
|
| 40 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 41 |
+
self.response_splitter_config = response_splitter_config
|
| 42 |
+
self.response_splitter = SplitManager(config=response_splitter_config)
|
| 43 |
+
self.split_model = split_model or model_adapter
|
| 44 |
+
self.device = self.config.device
|
| 45 |
+
if self.model_adapter.tokenizer.pad_token is None:
|
| 46 |
+
self.model_adapter.tokenizer.pad_token = (
|
| 47 |
+
self.model_adapter.tokenizer.eos_token
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
def detect(
|
| 51 |
+
self,
|
| 52 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 53 |
+
labels: Optional[np.ndarray] = None,
|
| 54 |
+
**kwargs,
|
| 55 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 56 |
+
"""
|
| 57 |
+
Detects hallucinations at claim level using encoder embeddings.
|
| 58 |
+
|
| 59 |
+
Each response is split into atomic claims, classified independently,
|
| 60 |
+
then predictions are re-aggregated to response level.
|
| 61 |
+
"""
|
| 62 |
+
original_samples = list(samples)
|
| 63 |
+
|
| 64 |
+
samples_fact_splits = self.response_splitter.split_inputs(
|
| 65 |
+
samples, self.split_model
|
| 66 |
+
)
|
| 67 |
+
response_group_ids = []
|
| 68 |
+
for index, splits in enumerate(samples_fact_splits):
|
| 69 |
+
response_group_ids.extend([index] * len(splits))
|
| 70 |
+
samples = [split for splits in samples_fact_splits for split in splits]
|
| 71 |
+
|
| 72 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 73 |
+
|
| 74 |
+
self._check_truncation_warning(samples)
|
| 75 |
+
|
| 76 |
+
model_states = self.model_adapter.generate_hiddens(
|
| 77 |
+
inputs=samples,
|
| 78 |
+
return_hiddens=False,
|
| 79 |
+
return_logits=True,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
logits = model_states.logits
|
| 83 |
+
|
| 84 |
+
probs, preds = self._logits_to_probs_preds(logits)
|
| 85 |
+
|
| 86 |
+
probs = probs.tolist()
|
| 87 |
+
preds = preds.tolist()
|
| 88 |
+
|
| 89 |
+
preds, probs = self._aggregate_context_predictions(
|
| 90 |
+
group_ids, preds, probs, binary=(self.config.num_classification_heads <= 2)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
overall_preds, overall_probs = self.response_splitter.aggregate_predictions(
|
| 94 |
+
response_group_ids,
|
| 95 |
+
preds,
|
| 96 |
+
probs,
|
| 97 |
+
binary=(self.config.num_classification_heads <= 2),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
results = []
|
| 101 |
+
for sample_idx, sample in enumerate(original_samples):
|
| 102 |
+
sample_mask = [
|
| 103 |
+
i for i, gid in enumerate(response_group_ids) if gid == sample_idx
|
| 104 |
+
]
|
| 105 |
+
splitted_samples = samples_fact_splits[sample_idx]
|
| 106 |
+
sample_probs = [probs[i] for i in sample_mask]
|
| 107 |
+
sample_preds = [preds[i] for i in sample_mask]
|
| 108 |
+
facts = [
|
| 109 |
+
{"fact": split[1]["content"], "pred": pred, "prob": prob}
|
| 110 |
+
for split, pred, prob in zip(splitted_samples, sample_probs, sample_preds)
|
| 111 |
+
]
|
| 112 |
+
results.append(
|
| 113 |
+
{
|
| 114 |
+
"sample": sample,
|
| 115 |
+
"overall_pred": overall_preds[sample_idx],
|
| 116 |
+
"overall_prob": overall_probs[sample_idx],
|
| 117 |
+
"facts": facts,
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
self.claim_results = results
|
| 121 |
+
|
| 122 |
+
return overall_probs, overall_preds, labels
|
| 123 |
+
|
| 124 |
+
def _compute_metrics(self, eval_pred: EvalPrediction) -> Dict[str, Any]:
|
| 125 |
+
"""Compute metrics for claim-level classification."""
|
| 126 |
+
logits, labels = eval_pred
|
| 127 |
+
|
| 128 |
+
probs, _ = process_logits_to_probs(logits, labels, filter_padding=False)
|
| 129 |
+
|
| 130 |
+
is_binary = logits.shape[-1] <= 2
|
| 131 |
+
metrics, threshold = calibrate_and_compute_metrics(
|
| 132 |
+
probs, labels, self.config, self.metrics_to_compute, is_binary
|
| 133 |
+
)
|
| 134 |
+
self.threshold = threshold
|
| 135 |
+
|
| 136 |
+
return metrics
|
| 137 |
+
|
| 138 |
+
def _preprocess(self, samples: Dict[str, Any]) -> Dict[str, Any]:
|
| 139 |
+
"""Preprocess samples for claim-level classification."""
|
| 140 |
+
inputs = samples[INPUT_COL]
|
| 141 |
+
targets = samples[TARGET_COL]
|
| 142 |
+
|
| 143 |
+
self._check_truncation_warning(inputs)
|
| 144 |
+
|
| 145 |
+
processed_inputs = self.model_adapter._preprocess_input(inputs)
|
| 146 |
+
|
| 147 |
+
tokenized = self.model_adapter.tokenizer(
|
| 148 |
+
processed_inputs,
|
| 149 |
+
padding=False,
|
| 150 |
+
truncation=self.model_adapter.config.truncation,
|
| 151 |
+
add_special_tokens=False,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
tokenized["labels"] = targets
|
| 155 |
+
|
| 156 |
+
return tokenized
|
| 157 |
+
|
| 158 |
+
def _save_config(self, save_dir: Path):
|
| 159 |
+
super()._save_config(save_dir)
|
| 160 |
+
joblib.dump(self.response_splitter_config, save_dir / "splitter_config.joblib")
|
| 161 |
+
lg.info(f"Saved splitter config to {save_dir / 'splitter_config.joblib'}")
|
| 162 |
+
|
| 163 |
+
def _load_config(self, load_dir: Path):
|
| 164 |
+
super()._load_config(load_dir)
|
| 165 |
+
splitter_config_path = load_dir / "splitter_config.joblib"
|
| 166 |
+
if splitter_config_path.exists():
|
| 167 |
+
self.response_splitter_config = joblib.load(splitter_config_path)
|
| 168 |
+
self.response_splitter = SplitManager(config=self.response_splitter_config)
|
| 169 |
+
lg.info(f"Loaded splitter config from {splitter_config_path}")
|
sirin/detection/judging/judges/claim/openai.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Dict, List, Optional, Tuple, Union
|
| 3 |
+
|
| 4 |
+
import joblib
|
| 5 |
+
import numpy as np
|
| 6 |
+
from loguru import logger as lg
|
| 7 |
+
|
| 8 |
+
from sirin.definitions import DetectionLevel
|
| 9 |
+
from sirin.detection.judging.judges.base import OpenAIJudgeBase
|
| 10 |
+
from sirin.detection.judging.judges.utils import build_prompt_messages
|
| 11 |
+
from sirin.detection.splitters import SplitManager
|
| 12 |
+
from sirin.inference.adapters import ModelAdapterBase
|
| 13 |
+
from sirin.models.detection import OpenAIJudgeConfig, SplitConfig
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ClaimOpenAIJudge(OpenAIJudgeBase):
|
| 17 |
+
"""
|
| 18 |
+
OpenAI API judge implementation for claim-level hallucination detection.
|
| 19 |
+
|
| 20 |
+
Decomposes each response into atomic claims via SplitManager, classifies
|
| 21 |
+
each claim independently via the OpenAI API, then re-aggregates per-claim
|
| 22 |
+
predictions back to response level. No training support.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
detection_level = DetectionLevel.CLAIM
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
config: OpenAIJudgeConfig,
|
| 30 |
+
model_adapter: ModelAdapterBase,
|
| 31 |
+
response_splitter_config: SplitConfig,
|
| 32 |
+
split_model: Optional[ModelAdapterBase] = None,
|
| 33 |
+
):
|
| 34 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 35 |
+
self.response_splitter_config = response_splitter_config
|
| 36 |
+
self.response_splitter = SplitManager(config=response_splitter_config)
|
| 37 |
+
self.split_model = split_model or model_adapter
|
| 38 |
+
|
| 39 |
+
def detect(
|
| 40 |
+
self,
|
| 41 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 42 |
+
labels: Optional[np.ndarray] = None,
|
| 43 |
+
**kwargs,
|
| 44 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 45 |
+
"""
|
| 46 |
+
Detects hallucinations at claim level using the OpenAI API.
|
| 47 |
+
|
| 48 |
+
Each response is split into atomic claims, classified independently
|
| 49 |
+
via API logprobs, then predictions are re-aggregated to response level.
|
| 50 |
+
"""
|
| 51 |
+
original_samples = list(samples)
|
| 52 |
+
|
| 53 |
+
samples_fact_splits = self.response_splitter.split_inputs(
|
| 54 |
+
samples, self.split_model
|
| 55 |
+
)
|
| 56 |
+
response_group_ids = []
|
| 57 |
+
for index, splits in enumerate(samples_fact_splits):
|
| 58 |
+
response_group_ids.extend([index] * len(splits))
|
| 59 |
+
samples = [split for splits in samples_fact_splits for split in splits]
|
| 60 |
+
|
| 61 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 62 |
+
|
| 63 |
+
formatted_input = build_prompt_messages(self.config, samples)
|
| 64 |
+
|
| 65 |
+
results, logprobs_results = self.model_adapter.sample(
|
| 66 |
+
inputs=formatted_input,
|
| 67 |
+
max_tokens=1,
|
| 68 |
+
temperature=self.config.temperature,
|
| 69 |
+
top_p=self.config.top_p,
|
| 70 |
+
return_logprobs=True,
|
| 71 |
+
top_logprobs=2,
|
| 72 |
+
**kwargs,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
probs = []
|
| 76 |
+
preds = []
|
| 77 |
+
|
| 78 |
+
for idx, logprob_result in enumerate(logprobs_results):
|
| 79 |
+
# models that omit logprobs get a nan score (not a crash); the verdict still holds.
|
| 80 |
+
if logprob_result and logprob_result[0]:
|
| 81 |
+
probs.append(-1 * logprob_result[0][0])
|
| 82 |
+
else:
|
| 83 |
+
probs.append(float('nan'))
|
| 84 |
+
|
| 85 |
+
text = str(results[idx]).strip()
|
| 86 |
+
preds.append(int(text) if text.isdigit() else 0)
|
| 87 |
+
|
| 88 |
+
preds, probs = self._aggregate_context_predictions(
|
| 89 |
+
group_ids, preds, probs, binary=(self.config.num_classification_heads <= 2)
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
overall_preds, overall_probs = self.response_splitter.aggregate_predictions(
|
| 93 |
+
response_group_ids,
|
| 94 |
+
preds,
|
| 95 |
+
probs,
|
| 96 |
+
binary=(self.config.num_classification_heads <= 2),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
results = []
|
| 100 |
+
for sample_idx, sample in enumerate(original_samples):
|
| 101 |
+
sample_mask = [
|
| 102 |
+
i for i, gid in enumerate(response_group_ids) if gid == sample_idx
|
| 103 |
+
]
|
| 104 |
+
splitted_samples = samples_fact_splits[sample_idx]
|
| 105 |
+
sample_probs = [probs[i] for i in sample_mask]
|
| 106 |
+
sample_preds = [preds[i] for i in sample_mask]
|
| 107 |
+
facts = [
|
| 108 |
+
{"fact": split[1]["content"], "pred": pred, "prob": prob}
|
| 109 |
+
for split, pred, prob in zip(splitted_samples, sample_probs, sample_preds)
|
| 110 |
+
]
|
| 111 |
+
results.append(
|
| 112 |
+
{
|
| 113 |
+
"sample": sample,
|
| 114 |
+
"overall_pred": overall_preds[sample_idx],
|
| 115 |
+
"overall_prob": overall_probs[sample_idx],
|
| 116 |
+
"facts": facts,
|
| 117 |
+
}
|
| 118 |
+
)
|
| 119 |
+
self.claim_results = results
|
| 120 |
+
|
| 121 |
+
return overall_probs, overall_preds, labels
|
| 122 |
+
|
| 123 |
+
def _save_config(self, save_dir: Path):
|
| 124 |
+
super()._save_config(save_dir)
|
| 125 |
+
joblib.dump(self.response_splitter_config, save_dir / "splitter_config.joblib")
|
| 126 |
+
lg.info(f"Saved splitter config to {save_dir / 'splitter_config.joblib'}")
|
| 127 |
+
|
| 128 |
+
def _load_config(self, load_dir: Path):
|
| 129 |
+
super()._load_config(load_dir)
|
| 130 |
+
splitter_config_path = load_dir / "splitter_config.joblib"
|
| 131 |
+
if splitter_config_path.exists():
|
| 132 |
+
self.response_splitter_config = joblib.load(splitter_config_path)
|
| 133 |
+
self.response_splitter = SplitManager(config=self.response_splitter_config)
|
| 134 |
+
lg.info(f"Loaded splitter config from {splitter_config_path}")
|
sirin/detection/judging/judges/sequence/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .decoder import SequenceDecoderJudge
|
| 2 |
+
from .encoder import SequenceEncoderJudge
|
| 3 |
+
from .openai import SequenceOpenAIJudge
|
sirin/detection/judging/judges/sequence/decoder.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from loguru import logger as lg
|
| 6 |
+
from transformers import EvalPrediction
|
| 7 |
+
|
| 8 |
+
from sirin.definitions import INPUT_COL, TARGET_COL, DetectionLevel, DataCollatorType
|
| 9 |
+
from sirin.detection.judging.judges.base import HfJudgeBase
|
| 10 |
+
from sirin.detection.judging.judges.utils import (
|
| 11 |
+
format_dialogue_for_training,
|
| 12 |
+
prepare_decoder_inputs_with_labels,
|
| 13 |
+
build_prompt_messages,
|
| 14 |
+
)
|
| 15 |
+
from sirin.detection.judging.training import SequenceDecoderJudgeTrainer
|
| 16 |
+
from sirin.detection.utils.basic import calibrate_threshold
|
| 17 |
+
from sirin.inference.adapters import HfModelAdapter
|
| 18 |
+
from sirin.metrics.classification import calculate_classification_metrics
|
| 19 |
+
from sirin.models.detection import HfJudgeConfig
|
| 20 |
+
from sirin.utils.hf import get_assistant_prefix
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class SequenceDecoderJudge(HfJudgeBase):
|
| 24 |
+
"""
|
| 25 |
+
Decoder judge implementation for sequence-level hallucination detection.
|
| 26 |
+
|
| 27 |
+
Uses generative decoder models (e.g., Llama, GPT) with next-token prediction.
|
| 28 |
+
Predicts a class token (0, 1, etc.) for the entire sequence.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
detection_level = DetectionLevel.SEQUENCE
|
| 32 |
+
data_collator_type = DataCollatorType.TOKEN
|
| 33 |
+
trainer = SequenceDecoderJudgeTrainer
|
| 34 |
+
|
| 35 |
+
def __init__(self, config: HfJudgeConfig, model_adapter: HfModelAdapter):
|
| 36 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 37 |
+
self.assistant_prefix = get_assistant_prefix(self.model_adapter._model_name)
|
| 38 |
+
self.last_generations: list[str] | None = None # generated text is not locally decoded here.
|
| 39 |
+
|
| 40 |
+
def detect(
|
| 41 |
+
self,
|
| 42 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 43 |
+
labels: Optional[np.ndarray] = None,
|
| 44 |
+
**kwargs,
|
| 45 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 46 |
+
"""
|
| 47 |
+
Architecture: Uses generative decoder model (e.g., Llama, GPT).
|
| 48 |
+
Differs from encoder judges by using next-token prediction instead of classification head.
|
| 49 |
+
Predicts a class token (0, 1, etc.) as the first generated token.
|
| 50 |
+
"""
|
| 51 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 52 |
+
|
| 53 |
+
self._check_truncation_warning(samples)
|
| 54 |
+
|
| 55 |
+
formatted_input = build_prompt_messages(self.config, samples)
|
| 56 |
+
|
| 57 |
+
preprocessed_input = self.model_adapter._preprocess_input(formatted_input)
|
| 58 |
+
preprocessed_input = [
|
| 59 |
+
f"{sample}{self.assistant_prefix}" for sample in preprocessed_input
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
inputs = self.model_adapter.tokenizer(
|
| 63 |
+
preprocessed_input,
|
| 64 |
+
return_tensors=self.config.return_tensors,
|
| 65 |
+
truncation=self.model_adapter.config.truncation,
|
| 66 |
+
padding=self.model_adapter.config.padding,
|
| 67 |
+
).to(self.config.device)
|
| 68 |
+
|
| 69 |
+
# Decoder approach: Generate next token and extract logits for class tokens
|
| 70 |
+
with torch.no_grad():
|
| 71 |
+
outputs = self.model_adapter.model.generate(
|
| 72 |
+
**inputs,
|
| 73 |
+
max_new_tokens=self.config.max_new_tokens,
|
| 74 |
+
do_sample=False,
|
| 75 |
+
temperature=self.config.temperature,
|
| 76 |
+
top_p=self.config.top_p,
|
| 77 |
+
pad_token_id=self.model_adapter.tokenizer.eos_token_id,
|
| 78 |
+
eos_token_id=self.model_adapter.tokenizer.eos_token_id,
|
| 79 |
+
return_dict_in_generate=True,
|
| 80 |
+
output_scores=True,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Extract logits for class tokens (e.g., "0", "1") from first generated position
|
| 84 |
+
first_token_logits = outputs.scores[0]
|
| 85 |
+
selected_logits = first_token_logits[:, self.class_token_ids]
|
| 86 |
+
probs, preds = self._logits_to_probs_preds(selected_logits)
|
| 87 |
+
|
| 88 |
+
probs = probs.tolist()
|
| 89 |
+
preds = preds.tolist()
|
| 90 |
+
|
| 91 |
+
preds, probs = self._aggregate_context_predictions(
|
| 92 |
+
group_ids, preds, probs, binary=(self.config.num_classification_heads <= 2)
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return probs, preds, labels
|
| 96 |
+
|
| 97 |
+
def _compute_metrics(self, eval_pred: EvalPrediction) -> Dict[str, Any]:
|
| 98 |
+
"""Compute metrics for sequence-level classification."""
|
| 99 |
+
probs = eval_pred.predictions
|
| 100 |
+
labels = eval_pred.label_ids
|
| 101 |
+
|
| 102 |
+
if self.config.num_classification_heads == 2:
|
| 103 |
+
self.threshold = calibrate_threshold(
|
| 104 |
+
probs,
|
| 105 |
+
labels,
|
| 106 |
+
self.config.threshold_method,
|
| 107 |
+
self.config.threshold_percentile,
|
| 108 |
+
self.config.fixed_threshold,
|
| 109 |
+
)
|
| 110 |
+
preds = (probs > self.threshold).astype(int)
|
| 111 |
+
else:
|
| 112 |
+
self.threshold = None
|
| 113 |
+
preds = np.argmax(probs, axis=1)
|
| 114 |
+
|
| 115 |
+
metrics = calculate_classification_metrics(
|
| 116 |
+
labels,
|
| 117 |
+
probs,
|
| 118 |
+
preds,
|
| 119 |
+
metrics=self.metrics_to_compute,
|
| 120 |
+
)
|
| 121 |
+
lg.info(f"Evaluation metrics: {metrics}")
|
| 122 |
+
return metrics
|
| 123 |
+
|
| 124 |
+
def _preprocess(self, samples: Dict[str, Any]) -> Dict[str, Any]:
|
| 125 |
+
"""Preprocess samples for sequence-level classification with decoder models."""
|
| 126 |
+
inputs = samples[INPUT_COL]
|
| 127 |
+
targets = samples[TARGET_COL]
|
| 128 |
+
|
| 129 |
+
self._check_truncation_warning(inputs)
|
| 130 |
+
|
| 131 |
+
# Format dialogue samples with system/user/assistant roles
|
| 132 |
+
formatted_messages = format_dialogue_for_training(
|
| 133 |
+
inputs, targets, self.config, is_token_level=False
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Prepare tokenized inputs with proper label masking
|
| 137 |
+
return prepare_decoder_inputs_with_labels(
|
| 138 |
+
formatted_messages, self.model_adapter, self.config
|
| 139 |
+
)
|
sirin/detection/judging/judges/sequence/encoder.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from loguru import logger as lg
|
| 5 |
+
from transformers import EvalPrediction, Trainer
|
| 6 |
+
|
| 7 |
+
from sirin.definitions import INPUT_COL, TARGET_COL, DetectionLevel, DataCollatorType
|
| 8 |
+
from sirin.detection.judging.judges.base import HfJudgeBase
|
| 9 |
+
from sirin.detection.judging.judges.utils import (
|
| 10 |
+
process_logits_to_probs,
|
| 11 |
+
calibrate_and_compute_metrics,
|
| 12 |
+
)
|
| 13 |
+
from sirin.models.detection import HfJudgeConfig
|
| 14 |
+
from sirin.inference.adapters import HfModelAdapter
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SequenceEncoderJudge(HfJudgeBase):
|
| 18 |
+
"""
|
| 19 |
+
Encoder judge implementation for sequence-level hallucination detection.
|
| 20 |
+
|
| 21 |
+
Uses AutoModelForSequenceClassification with encoder embeddings + classification head.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
detection_level = DetectionLevel.SEQUENCE
|
| 25 |
+
data_collator_type = DataCollatorType.PADDING
|
| 26 |
+
trainer = Trainer
|
| 27 |
+
|
| 28 |
+
def __init__(self, config: HfJudgeConfig, model_adapter: HfModelAdapter):
|
| 29 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 30 |
+
self.device = self.config.device
|
| 31 |
+
if self.model_adapter.tokenizer.pad_token is None:
|
| 32 |
+
self.model_adapter.tokenizer.pad_token = (
|
| 33 |
+
self.model_adapter.tokenizer.eos_token
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def detect(
|
| 37 |
+
self,
|
| 38 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 39 |
+
labels: Optional[np.ndarray] = None,
|
| 40 |
+
**kwargs,
|
| 41 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 42 |
+
"""
|
| 43 |
+
Detects hallucinations at sequence level using encoder embeddings.
|
| 44 |
+
For binary: Returns 0 (no hallucination) or 1 (hallucination)
|
| 45 |
+
For multiclass: Returns predicted class index
|
| 46 |
+
|
| 47 |
+
Architecture: Uses encoder embeddings with classification head (AutoModelForSequenceClassification).
|
| 48 |
+
This differs from decoder judges which use generative models with next-token prediction.
|
| 49 |
+
"""
|
| 50 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 51 |
+
|
| 52 |
+
self._check_truncation_warning(samples)
|
| 53 |
+
|
| 54 |
+
# Encoder approach: Get logits directly from classification head
|
| 55 |
+
model_states = self.model_adapter.generate_hiddens(
|
| 56 |
+
inputs=samples,
|
| 57 |
+
return_hiddens=False,
|
| 58 |
+
return_logits=True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
logits = model_states.logits
|
| 62 |
+
|
| 63 |
+
probs, preds = self._logits_to_probs_preds(logits)
|
| 64 |
+
|
| 65 |
+
probs = probs.tolist()
|
| 66 |
+
preds = preds.tolist()
|
| 67 |
+
|
| 68 |
+
preds, probs = self._aggregate_context_predictions(
|
| 69 |
+
group_ids, preds, probs, binary=(self.config.num_classification_heads <= 2)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
return probs, preds, labels
|
| 73 |
+
|
| 74 |
+
def _compute_metrics(self, eval_pred: EvalPrediction) -> Dict[str, Any]:
|
| 75 |
+
"""Compute metrics for sequence-level classification."""
|
| 76 |
+
logits, labels = eval_pred
|
| 77 |
+
|
| 78 |
+
# Convert logits to probabilities (handles binary/multiclass)
|
| 79 |
+
probs, _ = process_logits_to_probs(logits, labels, filter_padding=False)
|
| 80 |
+
|
| 81 |
+
# Calibrate threshold and compute metrics
|
| 82 |
+
is_binary = logits.shape[-1] <= 2
|
| 83 |
+
metrics, threshold = calibrate_and_compute_metrics(
|
| 84 |
+
probs, labels, self.config, self.metrics_to_compute, is_binary
|
| 85 |
+
)
|
| 86 |
+
self.threshold = threshold
|
| 87 |
+
|
| 88 |
+
return metrics
|
| 89 |
+
|
| 90 |
+
def _preprocess(self, samples: Dict[str, Any]) -> Dict[str, Any]:
|
| 91 |
+
"""Preprocess samples for sequence-level classification."""
|
| 92 |
+
inputs = samples[INPUT_COL]
|
| 93 |
+
targets = samples[TARGET_COL]
|
| 94 |
+
|
| 95 |
+
self._check_truncation_warning(inputs)
|
| 96 |
+
|
| 97 |
+
processed_inputs = self.model_adapter._preprocess_input(inputs)
|
| 98 |
+
|
| 99 |
+
tokenized = self.model_adapter.tokenizer(
|
| 100 |
+
processed_inputs,
|
| 101 |
+
padding=False,
|
| 102 |
+
truncation=self.model_adapter.config.truncation,
|
| 103 |
+
add_special_tokens=False,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
tokenized["labels"] = targets
|
| 107 |
+
|
| 108 |
+
return tokenized
|
sirin/detection/judging/judges/sequence/openai.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from typing import Dict, List, Tuple, Union, Optional
|
| 3 |
+
|
| 4 |
+
from sirin.models.detection import OpenAIJudgeConfig
|
| 5 |
+
from sirin.detection.judging.judges.base import JudgeAnnotationError, OpenAIJudgeBase
|
| 6 |
+
from sirin.detection.judging.judges.utils import build_prompt_messages
|
| 7 |
+
from sirin.definitions import DetectionLevel, DataCollatorType
|
| 8 |
+
from sirin.inference.adapters import ModelAdapterBase
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SequenceOpenAIJudge(OpenAIJudgeBase):
|
| 12 |
+
"""
|
| 13 |
+
OpenAI API judge implementation for sequence-level hallucination detection.
|
| 14 |
+
|
| 15 |
+
Architecture: Uses OpenAI API (e.g., GPT-4) instead of local HuggingFace models.
|
| 16 |
+
No training support - inference only via API calls.
|
| 17 |
+
"""
|
| 18 |
+
detection_level = DetectionLevel.SEQUENCE
|
| 19 |
+
|
| 20 |
+
def __init__(self, config: OpenAIJudgeConfig, model_adapter: ModelAdapterBase):
|
| 21 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 22 |
+
self.last_generations: list[str] | None = None
|
| 23 |
+
|
| 24 |
+
def detect(
|
| 25 |
+
self,
|
| 26 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 27 |
+
labels: Optional[np.ndarray] = None,
|
| 28 |
+
**kwargs,
|
| 29 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 30 |
+
"""
|
| 31 |
+
Uses OpenAI API for sequence-level classification.
|
| 32 |
+
Returns logprobs from API instead of running local model inference.
|
| 33 |
+
"""
|
| 34 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 35 |
+
formatted_input = build_prompt_messages(self.config, samples)
|
| 36 |
+
|
| 37 |
+
def _sample(return_logprobs: bool):
|
| 38 |
+
return self.model_adapter.sample(
|
| 39 |
+
inputs=formatted_input,
|
| 40 |
+
max_tokens=self.config.verdict_max_tokens,
|
| 41 |
+
temperature=self.config.temperature,
|
| 42 |
+
top_p=self.config.top_p,
|
| 43 |
+
return_logprobs=return_logprobs,
|
| 44 |
+
top_logprobs=2,
|
| 45 |
+
**kwargs
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
results, logprobs_results = _sample(return_logprobs=True)
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
# Some providers reject logprobs outright (400): retry once without them and
|
| 52 |
+
# keep the honest no-score verdict (nan). Anything else propagates unchanged.
|
| 53 |
+
try:
|
| 54 |
+
import openai
|
| 55 |
+
logprobs_rejected = (
|
| 56 |
+
isinstance(exc, openai.BadRequestError)
|
| 57 |
+
and 'logprob' in str(exc).lower()
|
| 58 |
+
)
|
| 59 |
+
except ImportError:
|
| 60 |
+
logprobs_rejected = False
|
| 61 |
+
if not logprobs_rejected:
|
| 62 |
+
raise
|
| 63 |
+
results = _sample(return_logprobs=False)
|
| 64 |
+
logprobs_results = [None] * len(results)
|
| 65 |
+
self.last_generations = list(results)
|
| 66 |
+
|
| 67 |
+
probs = []
|
| 68 |
+
preds = []
|
| 69 |
+
|
| 70 |
+
num_classes = max(self.config.num_classification_heads, 2)
|
| 71 |
+
for idx, logprob_result in enumerate(logprobs_results):
|
| 72 |
+
text = str(results[idx]).strip()
|
| 73 |
+
# Reasoning models return prose in content; take the first valid class digit.
|
| 74 |
+
digit = next(
|
| 75 |
+
(int(c) for c in text if c.isdigit() and int(c) < num_classes), None
|
| 76 |
+
)
|
| 77 |
+
if digit is None:
|
| 78 |
+
# Never fabricate a verdict when no valid class digit appears anywhere.
|
| 79 |
+
raise JudgeAnnotationError(
|
| 80 |
+
f'The judge model did not answer with a bare class digit and no class '
|
| 81 |
+
f'digit appears anywhere in its answer (got {text!r}). Choose a judge '
|
| 82 |
+
'model that states the verdict digit, or raise verdict_max_tokens '
|
| 83 |
+
f'(currently {self.config.verdict_max_tokens}) so a reasoning model '
|
| 84 |
+
'can finish thinking before the digit.'
|
| 85 |
+
)
|
| 86 |
+
preds.append(digit)
|
| 87 |
+
# The adapter's logprobs carry floats only (no token text), so the first-token
|
| 88 |
+
# logprob is provably the digit's only when the whole answer IS the digit.
|
| 89 |
+
# Some models also omit logprobs entirely. Either way: nan, verdict still holds.
|
| 90 |
+
if logprob_result and logprob_result[0] and text == str(digit):
|
| 91 |
+
probs.append(-1 * logprob_result[0][0])
|
| 92 |
+
else:
|
| 93 |
+
probs.append(float('nan'))
|
| 94 |
+
|
| 95 |
+
preds, probs = self._aggregate_context_predictions(
|
| 96 |
+
group_ids, preds, probs, binary=(self.config.num_classification_heads <= 2)
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return probs, preds, labels
|
sirin/detection/judging/judges/token/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .decoder import TokenDecoderJudge
|
| 2 |
+
from .encoder import TokenEncoderJudge
|
| 3 |
+
from .openai import TokenOpenAIJudge
|
sirin/detection/judging/judges/token/decoder.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from transformers import Trainer
|
| 6 |
+
|
| 7 |
+
from sirin.definitions import INPUT_COL, TARGET_COL, DetectionLevel, DataCollatorType
|
| 8 |
+
from sirin.detection.judging.judges.base import HfJudgeBase
|
| 9 |
+
from sirin.detection.judging.judges.utils import (
|
| 10 |
+
format_dialogue_for_training,
|
| 11 |
+
prepare_decoder_inputs_with_labels,
|
| 12 |
+
build_prompt_messages,
|
| 13 |
+
calibrate_and_compute_metrics,
|
| 14 |
+
calculate_character_probabilities,
|
| 15 |
+
create_char_binary_vector,
|
| 16 |
+
find_span_segments,
|
| 17 |
+
)
|
| 18 |
+
from sirin.detection.utils.token import convert_spans_to_labels
|
| 19 |
+
from sirin.inference.adapters import ModelAdapterBase
|
| 20 |
+
from sirin.models.detection import HfJudgeConfig
|
| 21 |
+
from sirin.utils.hf import get_assistant_prefix
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TokenDecoderJudge(HfJudgeBase):
|
| 25 |
+
"""
|
| 26 |
+
Decoder judge implementation for token-level hallucination detection.
|
| 27 |
+
|
| 28 |
+
Uses generative decoder models with multiple beam samples to identify
|
| 29 |
+
hallucinated character spans based on generation consistency.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
detection_level = DetectionLevel.TOKEN
|
| 33 |
+
data_collator_type = DataCollatorType.TOKEN
|
| 34 |
+
trainer = Trainer
|
| 35 |
+
|
| 36 |
+
def __init__(self, config: HfJudgeConfig, model_adapter: ModelAdapterBase):
|
| 37 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 38 |
+
self.model_adapter.tokenizer.add_tokens(['[SPAN]', '[/SPAN]'], special_tokens=True)
|
| 39 |
+
self.model_adapter.model.resize_token_embeddings(len(self.model_adapter.tokenizer))
|
| 40 |
+
self.assistant_prefix = get_assistant_prefix(self.model_adapter._model_name)
|
| 41 |
+
self.class_token_ids = None
|
| 42 |
+
self.last_generations: list[str] | None = None
|
| 43 |
+
self.last_spans: list[list[tuple[int, int]]] | None = None
|
| 44 |
+
|
| 45 |
+
def detect(
|
| 46 |
+
self, samples: Union[List[str], List[List[Dict]]], labels: Optional[np.ndarray] = None, **kwargs
|
| 47 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 48 |
+
"""
|
| 49 |
+
Architecture: Uses generative decoder model with multiple beam samples.
|
| 50 |
+
Differs from sequence decoder by identifying character-level hallucination spans.
|
| 51 |
+
Uses generation consistency across beams: characters wrapped in [SPAN] tags
|
| 52 |
+
by multiple generations are marked as hallucinated.
|
| 53 |
+
"""
|
| 54 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 55 |
+
|
| 56 |
+
self._check_truncation_warning(samples)
|
| 57 |
+
|
| 58 |
+
formatted_input = build_prompt_messages(self.config, samples)
|
| 59 |
+
|
| 60 |
+
preprocessed_input = self.model_adapter._preprocess_input(formatted_input)
|
| 61 |
+
preprocessed_input = [
|
| 62 |
+
f'{sample}{self.assistant_prefix}' for sample in preprocessed_input
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
# Generate multiple sequences per sample for consistency checking
|
| 66 |
+
generated_texts = self.model_adapter.sample(
|
| 67 |
+
inputs=preprocessed_input,
|
| 68 |
+
max_tokens=self.config.max_new_tokens,
|
| 69 |
+
temperature=self.config.temperature,
|
| 70 |
+
top_p=self.config.top_p,
|
| 71 |
+
num_return_sequences=self.config.num_beams,
|
| 72 |
+
do_sample=True,
|
| 73 |
+
pad_token_id=self.model_adapter.tokenizer.eos_token_id,
|
| 74 |
+
eos_token_id=self.model_adapter.tokenizer.eos_token_id,
|
| 75 |
+
**kwargs
|
| 76 |
+
)
|
| 77 |
+
self.last_generations = list(generated_texts)
|
| 78 |
+
self.last_spans = [find_span_segments(text) for text in generated_texts]
|
| 79 |
+
|
| 80 |
+
# Group generated sequences by input sample
|
| 81 |
+
all_generated_sequences = []
|
| 82 |
+
num_beams = self.config.num_beams
|
| 83 |
+
|
| 84 |
+
for i in range(len(samples)):
|
| 85 |
+
start_idx = i * num_beams
|
| 86 |
+
sample_sequences = generated_texts[start_idx:start_idx + num_beams]
|
| 87 |
+
all_generated_sequences.append(sample_sequences)
|
| 88 |
+
|
| 89 |
+
# Calculate character-level probabilities from span tag consistency
|
| 90 |
+
all_char_probs = []
|
| 91 |
+
all_char_preds = []
|
| 92 |
+
|
| 93 |
+
for generated_texts in all_generated_sequences:
|
| 94 |
+
sample_char_probs = calculate_character_probabilities(generated_texts)
|
| 95 |
+
sample_char_probs = torch.tensor(sample_char_probs)
|
| 96 |
+
all_char_probs.append(sample_char_probs)
|
| 97 |
+
all_char_preds.append((sample_char_probs > self.threshold).long())
|
| 98 |
+
|
| 99 |
+
char_probs = [prob.tolist() for prob in all_char_probs]
|
| 100 |
+
char_preds = [pred.tolist() for pred in all_char_preds]
|
| 101 |
+
|
| 102 |
+
char_preds, char_probs = self._aggregate_context_predictions(
|
| 103 |
+
group_ids, char_preds, char_probs, binary=(self.config.num_classification_heads <= 2)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Convert span annotations to character-level labels using shared utility
|
| 107 |
+
char_labels = convert_spans_to_labels(labels, char_probs) if labels is not None else None
|
| 108 |
+
|
| 109 |
+
return char_probs, char_preds, char_labels
|
| 110 |
+
|
| 111 |
+
def _compute_metrics(self, eval_pred: Any) -> Dict[str, Any]:
|
| 112 |
+
"""
|
| 113 |
+
Compute metrics for token-level decoder judge.
|
| 114 |
+
|
| 115 |
+
Converts token-level logits into predicted span-tagged text (teacher-forced),
|
| 116 |
+
then derives character-level binary vectors for metric computation.
|
| 117 |
+
"""
|
| 118 |
+
logits = getattr(eval_pred, "predictions", eval_pred[0])
|
| 119 |
+
labels = getattr(eval_pred, "label_ids", eval_pred[1])
|
| 120 |
+
|
| 121 |
+
if isinstance(logits, (tuple, list)):
|
| 122 |
+
logits = logits[0]
|
| 123 |
+
|
| 124 |
+
pred_ids = np.argmax(logits, axis=-1)
|
| 125 |
+
|
| 126 |
+
probs_list = []
|
| 127 |
+
labels_list = []
|
| 128 |
+
|
| 129 |
+
tokenizer = self.model_adapter.tokenizer
|
| 130 |
+
for pred_ids_sample, labels_sample in zip(pred_ids, labels):
|
| 131 |
+
active_mask = labels_sample != -100
|
| 132 |
+
if not np.any(active_mask):
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
label_ids = labels_sample[active_mask]
|
| 136 |
+
pred_ids_active = pred_ids_sample[active_mask]
|
| 137 |
+
|
| 138 |
+
label_text = tokenizer.decode(label_ids, skip_special_tokens=False)
|
| 139 |
+
pred_text = tokenizer.decode(pred_ids_active, skip_special_tokens=False)
|
| 140 |
+
|
| 141 |
+
label_vector = create_char_binary_vector(label_text)
|
| 142 |
+
pred_vector = create_char_binary_vector(pred_text)
|
| 143 |
+
|
| 144 |
+
if not label_vector or not pred_vector:
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
min_len = min(len(label_vector), len(pred_vector))
|
| 148 |
+
if min_len == 0:
|
| 149 |
+
continue
|
| 150 |
+
|
| 151 |
+
labels_list.append(np.array(label_vector[:min_len]))
|
| 152 |
+
probs_list.append(np.array(pred_vector[:min_len], dtype=float))
|
| 153 |
+
|
| 154 |
+
if not labels_list:
|
| 155 |
+
return {}
|
| 156 |
+
|
| 157 |
+
labels_flat = np.concatenate(labels_list)
|
| 158 |
+
probs_flat = np.concatenate(probs_list)
|
| 159 |
+
|
| 160 |
+
metrics, threshold = calibrate_and_compute_metrics(
|
| 161 |
+
probs_flat,
|
| 162 |
+
labels_flat,
|
| 163 |
+
self.config,
|
| 164 |
+
self.metrics_to_compute,
|
| 165 |
+
is_binary=True,
|
| 166 |
+
)
|
| 167 |
+
self.threshold = threshold
|
| 168 |
+
|
| 169 |
+
return metrics
|
| 170 |
+
|
| 171 |
+
def _preprocess(self, samples: Dict[str, Any]) -> Dict[str, Any]:
|
| 172 |
+
"""Preprocess samples for token-level classification with decoder models."""
|
| 173 |
+
inputs = samples[INPUT_COL]
|
| 174 |
+
targets = samples[TARGET_COL]
|
| 175 |
+
|
| 176 |
+
self._check_truncation_warning(inputs)
|
| 177 |
+
|
| 178 |
+
# Format dialogue samples with system/user/assistant roles (with span wrapping)
|
| 179 |
+
formatted_messages = format_dialogue_for_training(
|
| 180 |
+
inputs, targets, self.config, is_token_level=True
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Prepare tokenized inputs with proper label masking
|
| 184 |
+
return prepare_decoder_inputs_with_labels(
|
| 185 |
+
formatted_messages, self.model_adapter, self.config
|
| 186 |
+
)
|
sirin/detection/judging/judges/token/encoder.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from loguru import logger as lg
|
| 6 |
+
from transformers import EvalPrediction, Trainer
|
| 7 |
+
|
| 8 |
+
from sirin.definitions import INPUT_COL, TARGET_COL, DetectionLevel, DataCollatorType
|
| 9 |
+
from sirin.detection.judging.judges.base import HfJudgeBase
|
| 10 |
+
from sirin.detection.judging.judges.utils import (
|
| 11 |
+
process_logits_to_probs,
|
| 12 |
+
calibrate_and_compute_metrics,
|
| 13 |
+
rearrange_token_predictions_with_indices,
|
| 14 |
+
repeat_labels_with_offsets,
|
| 15 |
+
)
|
| 16 |
+
from sirin.detection.utils.token import get_token_labels, get_answer_offsets
|
| 17 |
+
from sirin.models.detection import HfJudgeConfig
|
| 18 |
+
from sirin.inference.adapters import HfModelAdapter
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TokenEncoderJudge(HfJudgeBase):
|
| 22 |
+
"""
|
| 23 |
+
Encoder judge implementation for token-level hallucination detection.
|
| 24 |
+
|
| 25 |
+
Uses AutoModelForTokenClassification with encoder embeddings + token classification head.
|
| 26 |
+
Handles per-token predictions with offset mapping for character-level spans.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
detection_level = DetectionLevel.TOKEN
|
| 30 |
+
data_collator_type = DataCollatorType.TOKEN
|
| 31 |
+
trainer = Trainer
|
| 32 |
+
|
| 33 |
+
def __init__(self, config: HfJudgeConfig, model_adapter: HfModelAdapter):
|
| 34 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 35 |
+
self.device = self.config.device
|
| 36 |
+
if self.model_adapter.tokenizer.pad_token is None:
|
| 37 |
+
self.model_adapter.tokenizer.pad_token = self.model_adapter.tokenizer.eos_token
|
| 38 |
+
|
| 39 |
+
def detect(
|
| 40 |
+
self,
|
| 41 |
+
samples: List[str],
|
| 42 |
+
labels: Optional[np.ndarray] = None,
|
| 43 |
+
**kwargs,
|
| 44 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 45 |
+
"""
|
| 46 |
+
Detects hallucinations at token level using encoder embeddings.
|
| 47 |
+
For binary: Returns 0 (no hallucination) or 1 (hallucination)
|
| 48 |
+
|
| 49 |
+
Architecture: Uses encoder embeddings with token classification head (AutoModelForTokenClassification).
|
| 50 |
+
Differs from sequence-level which returns one prediction per sample.
|
| 51 |
+
This approach requires offset mapping to align token predictions with character spans.
|
| 52 |
+
"""
|
| 53 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 54 |
+
|
| 55 |
+
self._check_truncation_warning(samples)
|
| 56 |
+
|
| 57 |
+
# Token-level approach: Get character offsets for each token in answer
|
| 58 |
+
offsets, _, answer_indices = get_answer_offsets(samples, self.model_adapter)
|
| 59 |
+
|
| 60 |
+
if labels is not None:
|
| 61 |
+
labels = get_token_labels(offsets, labels)
|
| 62 |
+
|
| 63 |
+
# Get per-token logits from token classification head
|
| 64 |
+
model_states = self.model_adapter.generate_hiddens(
|
| 65 |
+
inputs=samples,
|
| 66 |
+
return_hiddens=False,
|
| 67 |
+
return_logits=True,
|
| 68 |
+
**kwargs,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
logits = model_states.logits
|
| 72 |
+
|
| 73 |
+
probs, preds = self._logits_to_probs_preds(logits)
|
| 74 |
+
probs = probs.cpu()
|
| 75 |
+
preds = preds.cpu()
|
| 76 |
+
|
| 77 |
+
offsets = [torch.tensor(offset).long() for offset in offsets]
|
| 78 |
+
|
| 79 |
+
# Rearrange token predictions to character-level using shared utility
|
| 80 |
+
char_probs, char_preds = rearrange_token_predictions_with_indices(
|
| 81 |
+
probs, preds, offsets, answer_indices
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
char_preds, char_probs = self._aggregate_context_predictions(
|
| 85 |
+
group_ids, char_preds, char_probs, binary=(self.config.num_classification_heads <= 2)
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
if labels is not None:
|
| 89 |
+
repeats = [offset[:, 1] - offset[:, 0] for offset in offsets]
|
| 90 |
+
char_labels = repeat_labels_with_offsets(labels, repeats)
|
| 91 |
+
else:
|
| 92 |
+
char_labels = None
|
| 93 |
+
|
| 94 |
+
return char_probs, char_preds, char_labels
|
| 95 |
+
|
| 96 |
+
def _compute_metrics(self, eval_pred: EvalPrediction) -> Dict[str, Any]:
|
| 97 |
+
"""
|
| 98 |
+
Compute metrics for token-level classification.
|
| 99 |
+
|
| 100 |
+
Filters out padding tokens (-100) before computing metrics.
|
| 101 |
+
"""
|
| 102 |
+
logits, labels = eval_pred
|
| 103 |
+
|
| 104 |
+
# Convert logits to probabilities, filtering padding tokens
|
| 105 |
+
probs, active_labels = process_logits_to_probs(logits, labels, filter_padding=True)
|
| 106 |
+
|
| 107 |
+
# Token-level is always binary classification
|
| 108 |
+
metrics, threshold = calibrate_and_compute_metrics(
|
| 109 |
+
probs, active_labels, self.config, self.metrics_to_compute, is_binary=True
|
| 110 |
+
)
|
| 111 |
+
self.threshold = threshold
|
| 112 |
+
|
| 113 |
+
return metrics
|
| 114 |
+
|
| 115 |
+
def _preprocess(self, samples: Dict[str, Any]) -> Dict[str, Any]:
|
| 116 |
+
"""Preprocess samples for token-level classification."""
|
| 117 |
+
inputs = samples[INPUT_COL]
|
| 118 |
+
targets = samples[TARGET_COL]
|
| 119 |
+
|
| 120 |
+
self._check_truncation_warning(inputs)
|
| 121 |
+
|
| 122 |
+
answer_offsets, tokenized, _ = get_answer_offsets(inputs, self.model_adapter)
|
| 123 |
+
labels_raw = get_token_labels(answer_offsets=answer_offsets, target_spans=targets)
|
| 124 |
+
|
| 125 |
+
labels = [[-100] * len(sample) for sample in tokenized['input_ids']]
|
| 126 |
+
for i in range(len(labels)):
|
| 127 |
+
labels[i][-len(labels_raw[i]):] = labels_raw[i]
|
| 128 |
+
|
| 129 |
+
tokenized['labels'] = labels
|
| 130 |
+
|
| 131 |
+
return tokenized
|
sirin/detection/judging/judges/token/openai.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Optional, Tuple, Union
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from sirin.definitions import DetectionLevel, DataCollatorType
|
| 7 |
+
from sirin.detection.judging.judges.base import JudgeAnnotationError, OpenAIJudgeBase
|
| 8 |
+
from sirin.detection.judging.judges.utils import (
|
| 9 |
+
build_prompt_messages,
|
| 10 |
+
calculate_character_probabilities,
|
| 11 |
+
extract_answer_from_generation,
|
| 12 |
+
find_span_segments,
|
| 13 |
+
)
|
| 14 |
+
from sirin.detection.utils.token import convert_spans_to_labels
|
| 15 |
+
from sirin.inference.adapters import OpenAIModelAdapter
|
| 16 |
+
from sirin.models.detection import OpenAIJudgeConfig
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TokenOpenAIJudge(OpenAIJudgeBase):
|
| 20 |
+
"""
|
| 21 |
+
OpenAI API judge implementation for token-level hallucination detection.
|
| 22 |
+
|
| 23 |
+
Architecture: Uses OpenAI API with multiple generations to identify character-level spans.
|
| 24 |
+
Similar to TokenDecoderJudge but uses API instead of local model.
|
| 25 |
+
No training support - inference only via API calls.
|
| 26 |
+
"""
|
| 27 |
+
detection_level = DetectionLevel.TOKEN
|
| 28 |
+
|
| 29 |
+
def __init__(self, config: OpenAIJudgeConfig, model_adapter: OpenAIModelAdapter):
|
| 30 |
+
super().__init__(config=config, model_adapter=model_adapter)
|
| 31 |
+
self.class_token_ids = None
|
| 32 |
+
self.last_generations: list[str] | None = None
|
| 33 |
+
self.last_spans: list[list[tuple[int, int]]] | None = None
|
| 34 |
+
self.last_consensus: list[dict] | None = None
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
def _reference_answer(sample: Union[str, List[Dict]]) -> str:
|
| 38 |
+
"""The assistant answer the char scores must align to (matches format_dialogue_samples)."""
|
| 39 |
+
if isinstance(sample, list) and len(sample) >= 2 and isinstance(sample[1], dict):
|
| 40 |
+
return sample[1]['content']
|
| 41 |
+
return sample
|
| 42 |
+
|
| 43 |
+
@staticmethod
|
| 44 |
+
def _echo_of(generation: Optional[str]) -> str:
|
| 45 |
+
"""A generation's answer with reasoning wrapper and span tags removed, for echo checking.
|
| 46 |
+
|
| 47 |
+
A ``None``/empty generation (a provider may return empty content, e.g. when a reasoning
|
| 48 |
+
budget is exhausted) is just an invalid vote: it returns '' and fails the echo check.
|
| 49 |
+
"""
|
| 50 |
+
if not generation:
|
| 51 |
+
return ''
|
| 52 |
+
text = extract_answer_from_generation(generation) # strips <think>… and outer whitespace
|
| 53 |
+
return text.replace('[SPAN]', '').replace('[/SPAN]', '').strip()
|
| 54 |
+
|
| 55 |
+
def detect(
|
| 56 |
+
self,
|
| 57 |
+
samples: Union[List[str], List[List[Dict]]],
|
| 58 |
+
labels: Optional[np.ndarray] = None,
|
| 59 |
+
**kwargs,
|
| 60 |
+
) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
| 61 |
+
"""
|
| 62 |
+
Uses OpenAI API for token-level classification with multiple generations.
|
| 63 |
+
|
| 64 |
+
Each sample is annotated by ``num_beams`` independent generations. A generation only
|
| 65 |
+
votes if it echoes the reference answer verbatim (after stripping any reasoning wrapper
|
| 66 |
+
and the span tags); paraphrases/misaligned echoes are dropped. Each character's score is
|
| 67 |
+
the fraction of VALID generations that flagged it, over the reference's characters. If no
|
| 68 |
+
generation validates for a sample we raise instead of emitting a misleading all-clear.
|
| 69 |
+
"""
|
| 70 |
+
samples, group_ids = self._split_context_samples(samples)
|
| 71 |
+
references = [self._reference_answer(sample) for sample in samples]
|
| 72 |
+
formatted_input = build_prompt_messages(self.config, samples)
|
| 73 |
+
|
| 74 |
+
n = self.config.num_beams
|
| 75 |
+
generated = self.model_adapter.sample(
|
| 76 |
+
inputs=formatted_input,
|
| 77 |
+
max_tokens=self.config.max_new_tokens, # 100-token default truncates a real answer.
|
| 78 |
+
temperature=self.config.temperature,
|
| 79 |
+
top_p=self.config.top_p,
|
| 80 |
+
n=n,
|
| 81 |
+
)
|
| 82 |
+
# Adapter contract: n==1 -> list[str] (one per sample); n>1 -> list[list[str]] (n per sample).
|
| 83 |
+
per_sample_gens = [[g] for g in generated] if n == 1 else generated
|
| 84 |
+
# Adapter side channel (n>1): per-sample finish_reasons aligned 1:1 with the generations;
|
| 85 |
+
# 'length' means the completion budget truncated that generation mid-reasoning.
|
| 86 |
+
per_sample_reasons = getattr(self.model_adapter, 'last_finish_reasons', None) or []
|
| 87 |
+
|
| 88 |
+
all_char_probs = []
|
| 89 |
+
all_char_preds = []
|
| 90 |
+
self.last_consensus = []
|
| 91 |
+
for i, (reference, gens) in enumerate(zip(references, per_sample_gens)):
|
| 92 |
+
reasons = list(per_sample_reasons[i]) if i < len(per_sample_reasons) else []
|
| 93 |
+
reasons += [None] * (len(gens) - len(reasons))
|
| 94 |
+
valid = []
|
| 95 |
+
invalid = {'truncated': 0, 'empty': 0, 'not_verbatim': 0}
|
| 96 |
+
for gen, reason in zip(gens, reasons):
|
| 97 |
+
if self._echo_of(gen) == str(reference).strip():
|
| 98 |
+
valid.append(gen)
|
| 99 |
+
elif reason == 'length':
|
| 100 |
+
invalid['truncated'] += 1
|
| 101 |
+
elif not gen:
|
| 102 |
+
invalid['empty'] += 1
|
| 103 |
+
else:
|
| 104 |
+
invalid['not_verbatim'] += 1
|
| 105 |
+
entry = {
|
| 106 |
+
'requested': n, 'valid': len(valid), 'temperature': self.config.temperature,
|
| 107 |
+
}
|
| 108 |
+
counts = {kind: count for kind, count in invalid.items() if count}
|
| 109 |
+
if counts:
|
| 110 |
+
entry['invalid'] = counts
|
| 111 |
+
self.last_consensus.append(entry)
|
| 112 |
+
if not valid:
|
| 113 |
+
raise JudgeAnnotationError(
|
| 114 |
+
f'No judge generation echoed the answer verbatim '
|
| 115 |
+
f'({len(gens)} generation(s) all dropped); cannot annotate spans.'
|
| 116 |
+
)
|
| 117 |
+
sample_char_probs = torch.tensor(
|
| 118 |
+
calculate_character_probabilities(valid, reference=reference)
|
| 119 |
+
)
|
| 120 |
+
all_char_probs.append(sample_char_probs)
|
| 121 |
+
all_char_preds.append((sample_char_probs > self.threshold).long())
|
| 122 |
+
|
| 123 |
+
# Expose sample 0's generations/spans for the UI (reads gens[0]/spans[0]).
|
| 124 |
+
self.last_generations = list(per_sample_gens[0]) if per_sample_gens else []
|
| 125 |
+
# `or ''`: a provider may return None content (e.g. exhausted reasoning budget) for one
|
| 126 |
+
# generation while the sample still validates on the others.
|
| 127 |
+
self.last_spans = [find_span_segments(g or '') for g in self.last_generations]
|
| 128 |
+
|
| 129 |
+
char_probs = [prob.tolist() for prob in all_char_probs]
|
| 130 |
+
char_preds = [pred.tolist() for pred in all_char_preds]
|
| 131 |
+
|
| 132 |
+
char_preds, char_probs = self._aggregate_context_predictions(
|
| 133 |
+
group_ids, char_preds, char_probs, binary=(self.config.num_classification_heads <= 2)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Convert span annotations to character-level labels using shared utility
|
| 137 |
+
char_labels = convert_spans_to_labels(labels, char_probs) if labels is not None else None
|
| 138 |
+
|
| 139 |
+
return char_probs, char_preds, char_labels
|
sirin/detection/judging/judges/utils/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sirin.detection.judging.judges.utils.decoder import (
|
| 2 |
+
prepare_decoder_inputs_with_labels,
|
| 3 |
+
)
|
| 4 |
+
from sirin.detection.judging.judges.utils.metrics import (
|
| 5 |
+
calibrate_and_compute_metrics,
|
| 6 |
+
process_logits_to_probs,
|
| 7 |
+
)
|
| 8 |
+
from sirin.detection.judging.judges.utils.prompts import (
|
| 9 |
+
build_prompt_messages,
|
| 10 |
+
format_dialogue_for_training,
|
| 11 |
+
format_dialogue_samples,
|
| 12 |
+
)
|
| 13 |
+
from sirin.detection.judging.judges.utils.token_level import (
|
| 14 |
+
calculate_character_probabilities,
|
| 15 |
+
create_char_binary_vector,
|
| 16 |
+
extract_answer_from_generation,
|
| 17 |
+
find_span_segments,
|
| 18 |
+
merge_overlapping_spans,
|
| 19 |
+
rearrange_token_predictions_with_indices,
|
| 20 |
+
repeat_labels_with_offsets,
|
| 21 |
+
wrap_spans,
|
| 22 |
+
)
|
| 23 |
+
from sirin.detection.utils.logits import logits_to_probs_preds
|
sirin/detection/judging/judges/utils/decoder.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List
|
| 2 |
+
|
| 3 |
+
from sirin.utils.hf import get_assistant_prefix
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def prepare_decoder_inputs_with_labels(
|
| 7 |
+
formatted_messages: List[List[Dict[str, str]]],
|
| 8 |
+
model_adapter: Any,
|
| 9 |
+
config: Any,
|
| 10 |
+
) -> Dict[str, Any]:
|
| 11 |
+
"""
|
| 12 |
+
Prepare inputs for decoder models with proper label masking.
|
| 13 |
+
|
| 14 |
+
Creates tokenized inputs where only the assistant response tokens are used for loss.
|
| 15 |
+
Context tokens (system + user prompts) are masked with -100.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
formatted_messages: Formatted message lists (system, user, assistant)
|
| 19 |
+
model_adapter: Model adapter with tokenizer and preprocessing
|
| 20 |
+
config: Judge configuration with tokenization settings
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Dict with 'input_ids', 'attention_mask', 'labels'
|
| 24 |
+
"""
|
| 25 |
+
# Get assistant prefix (e.g., "assistant\n" for the model's format)
|
| 26 |
+
assistant_prefix = get_assistant_prefix(model_adapter._model_name)
|
| 27 |
+
|
| 28 |
+
# Preprocess messages without assistant response to find context length
|
| 29 |
+
preprocessed_input_without_answer = [
|
| 30 |
+
f'{sample}{assistant_prefix}'
|
| 31 |
+
for sample in model_adapter._preprocess_input(
|
| 32 |
+
[sample[:2] for sample in formatted_messages] # Only system + user
|
| 33 |
+
)
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
# Preprocess full messages including assistant response
|
| 37 |
+
preprocessed_input = model_adapter._preprocess_input(formatted_messages)
|
| 38 |
+
|
| 39 |
+
# Tokenize without target (to find where assistant response starts)
|
| 40 |
+
encoding_without_target = model_adapter.tokenizer(
|
| 41 |
+
preprocessed_input_without_answer,
|
| 42 |
+
truncation=model_adapter.config.truncation,
|
| 43 |
+
padding=False,
|
| 44 |
+
add_special_tokens=False,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Tokenize with target
|
| 48 |
+
encoding = model_adapter.tokenizer(
|
| 49 |
+
preprocessed_input,
|
| 50 |
+
truncation=model_adapter.config.truncation,
|
| 51 |
+
padding=False,
|
| 52 |
+
return_tensors=config.return_tensors,
|
| 53 |
+
add_special_tokens=False,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Create labels: copy input_ids but mask everything before assistant response
|
| 57 |
+
labels = encoding['input_ids'].clone()
|
| 58 |
+
for i, sample in enumerate(encoding_without_target['input_ids']):
|
| 59 |
+
labels[i, :len(sample)] = -100 # Mask context tokens
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
'input_ids': encoding['input_ids'],
|
| 63 |
+
'attention_mask': encoding['attention_mask'],
|
| 64 |
+
'labels': labels,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
sirin/detection/judging/judges/utils/metrics.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from loguru import logger as lg
|
| 6 |
+
|
| 7 |
+
from sirin.definitions import ClassificationMetric
|
| 8 |
+
from sirin.detection.utils.basic import calibrate_threshold
|
| 9 |
+
from sirin.metrics.classification import calculate_classification_metrics
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def process_logits_to_probs(
|
| 13 |
+
logits: np.ndarray,
|
| 14 |
+
labels: Optional[np.ndarray] = None,
|
| 15 |
+
filter_padding: bool = False,
|
| 16 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 17 |
+
"""
|
| 18 |
+
Convert logits to probabilities, handling binary/multiclass cases.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
logits: Raw logits from model (shape: [batch, num_classes] or [batch, seq, num_classes])
|
| 22 |
+
labels: Optional labels for filtering padding tokens (token-level only)
|
| 23 |
+
filter_padding: Whether to filter out -100 padding labels (for token-level)
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
tuple of (probs, active_labels) where:
|
| 27 |
+
- probs: probability values
|
| 28 |
+
- active_labels: filtered labels (only if filter_padding=True, else original labels)
|
| 29 |
+
"""
|
| 30 |
+
# Handle token-level: flatten and filter padding
|
| 31 |
+
if filter_padding and labels is not None:
|
| 32 |
+
logits_flat = logits.reshape(-1, logits.shape[-1])
|
| 33 |
+
labels_flat = labels.reshape(-1)
|
| 34 |
+
|
| 35 |
+
active_mask = labels_flat != -100
|
| 36 |
+
logits = logits_flat[active_mask]
|
| 37 |
+
labels = labels_flat[active_mask]
|
| 38 |
+
|
| 39 |
+
logits_tensor = torch.from_numpy(logits) if isinstance(logits, np.ndarray) else logits
|
| 40 |
+
|
| 41 |
+
num_classes = logits_tensor.shape[-1]
|
| 42 |
+
|
| 43 |
+
if num_classes == 1:
|
| 44 |
+
# Binary classification with single output
|
| 45 |
+
probs = torch.sigmoid(logits_tensor).squeeze(-1)
|
| 46 |
+
elif num_classes == 2:
|
| 47 |
+
# Binary classification with two outputs
|
| 48 |
+
probs = torch.softmax(logits_tensor, dim=-1)[:, 1]
|
| 49 |
+
else:
|
| 50 |
+
# Multiclass classification
|
| 51 |
+
probs_full = torch.softmax(logits_tensor, dim=-1)
|
| 52 |
+
probs = torch.max(probs_full, dim=-1).values
|
| 53 |
+
|
| 54 |
+
probs_np = probs.numpy() if isinstance(probs, torch.Tensor) else probs
|
| 55 |
+
|
| 56 |
+
return probs_np, labels
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def calibrate_and_compute_metrics(
|
| 60 |
+
probs: np.ndarray,
|
| 61 |
+
labels: np.ndarray,
|
| 62 |
+
config: Any,
|
| 63 |
+
metrics_to_compute: List[ClassificationMetric],
|
| 64 |
+
is_binary: bool = True,
|
| 65 |
+
) -> tuple[Dict[str, Any], Optional[float]]:
|
| 66 |
+
"""
|
| 67 |
+
Calibrate threshold and compute classification metrics.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
probs: Probability predictions
|
| 71 |
+
labels: Ground truth labels
|
| 72 |
+
config: Judge configuration with threshold settings
|
| 73 |
+
metrics_to_compute: List of metrics to calculate
|
| 74 |
+
is_binary: Whether this is binary classification (affects threshold usage)
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
tuple of (metrics_dict, threshold) where:
|
| 78 |
+
- metrics_dict: Computed metric values
|
| 79 |
+
- threshold: Calibrated threshold (None for multiclass)
|
| 80 |
+
"""
|
| 81 |
+
if is_binary:
|
| 82 |
+
threshold = calibrate_threshold(
|
| 83 |
+
probs,
|
| 84 |
+
labels,
|
| 85 |
+
config.threshold_method,
|
| 86 |
+
config.threshold_percentile,
|
| 87 |
+
config.fixed_threshold,
|
| 88 |
+
)
|
| 89 |
+
preds = (probs > threshold).astype(int)
|
| 90 |
+
else:
|
| 91 |
+
threshold = None
|
| 92 |
+
if len(probs.shape) > 1:
|
| 93 |
+
# Multiclass: use argmax for predictions
|
| 94 |
+
preds = np.argmax(probs, axis=-1)
|
| 95 |
+
else:
|
| 96 |
+
preds = probs.astype(int)
|
| 97 |
+
|
| 98 |
+
metrics = calculate_classification_metrics(
|
| 99 |
+
labels,
|
| 100 |
+
probs,
|
| 101 |
+
preds,
|
| 102 |
+
metrics=metrics_to_compute,
|
| 103 |
+
)
|
| 104 |
+
lg.info(f"Evaluation metrics: {metrics}")
|
| 105 |
+
|
| 106 |
+
return metrics, threshold
|
sirin/detection/judging/judges/utils/prompts.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
# Span-annotation prompts for the token-level API judge. The judge parses [SPAN]...[/SPAN]
|
| 5 |
+
# tags out of the model's echoed answer, so the model MUST reproduce the answer verbatim and
|
| 6 |
+
# only insert tags — anything else (paraphrase, corrections, verdict digits) breaks the
|
| 7 |
+
# character alignment and is dropped by the reference-echo check.
|
| 8 |
+
SPAN_TAG_SYSTEM_PROMPT = (
|
| 9 |
+
"You are a hallucination span annotator. You receive a dialogue: a user prompt "
|
| 10 |
+
"(which may include a context passage) and an assistant answer. Reproduce the "
|
| 11 |
+
"assistant's answer EXACTLY, character for character, and insert the markers [SPAN] "
|
| 12 |
+
"and [/SPAN] around every hallucinated part — any span not supported by, or "
|
| 13 |
+
"contradicted by, the context.\n"
|
| 14 |
+
"Rules:\n"
|
| 15 |
+
"- Copy the answer verbatim. Do NOT correct, paraphrase, reorder, translate, or add "
|
| 16 |
+
"anything; the ONLY characters you may add are the [SPAN] and [/SPAN] markers.\n"
|
| 17 |
+
"- Wrap only the hallucinated words; you may use the markers multiple times.\n"
|
| 18 |
+
"- If nothing is hallucinated, output the answer unchanged with no markers.\n"
|
| 19 |
+
"- Output ONLY the annotated answer: no explanations, no verdict digits, no quotes, "
|
| 20 |
+
"no code fences."
|
| 21 |
+
)
|
| 22 |
+
SPAN_TAG_USER_PROMPT = (
|
| 23 |
+
"Dialogue:\n{sample}\n\n"
|
| 24 |
+
"Return the assistant's answer verbatim, wrapping each hallucinated span in "
|
| 25 |
+
"[SPAN]...[/SPAN]. If no content is hallucinated, return the answer unchanged."
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def format_dialogue_samples(config: Any, samples: List[Any]) -> List[Any]:
|
| 30 |
+
"""
|
| 31 |
+
Format dialogue samples using the configured dialogue format.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
config: Judge configuration with dialogue_format
|
| 35 |
+
samples: List of samples (either strings or list of message dicts)
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
List of formatted dialogue strings
|
| 39 |
+
"""
|
| 40 |
+
if not samples:
|
| 41 |
+
return []
|
| 42 |
+
|
| 43 |
+
first = samples[0]
|
| 44 |
+
if isinstance(first, list) and len(first) >= 2:
|
| 45 |
+
return [
|
| 46 |
+
config.dialogue_format.format(
|
| 47 |
+
**{'question': sample[0]['content'], 'answer': sample[1]['content']}
|
| 48 |
+
)
|
| 49 |
+
for sample in samples
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
return samples
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_prompt_messages(config: Any, samples: List[Any]) -> List[List[Dict[str, str]]]:
|
| 56 |
+
"""
|
| 57 |
+
Build full prompt messages with system and user roles.
|
| 58 |
+
|
| 59 |
+
Used by API-based judges (OpenAI) to create message lists for API calls.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
config: Judge configuration with system_prompt, user_prompt, dialogue_format
|
| 63 |
+
samples: List of samples to format
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
List of message lists, each containing system and user messages
|
| 67 |
+
"""
|
| 68 |
+
formatted_samples = format_dialogue_samples(config, samples)
|
| 69 |
+
|
| 70 |
+
return [
|
| 71 |
+
[
|
| 72 |
+
{'role': 'system', 'content': config.system_prompt},
|
| 73 |
+
{'role': 'user', 'content': config.user_prompt.format(**{'sample': sample})},
|
| 74 |
+
]
|
| 75 |
+
for sample in formatted_samples
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def format_dialogue_for_training(
|
| 80 |
+
inputs: List[List[Dict[str, str]]],
|
| 81 |
+
targets: List[Any],
|
| 82 |
+
config: Any,
|
| 83 |
+
is_token_level: bool = False,
|
| 84 |
+
) -> List[List[Dict[str, str]]]:
|
| 85 |
+
"""
|
| 86 |
+
Format dialogue samples with system/user/assistant roles for training.
|
| 87 |
+
|
| 88 |
+
Used by decoder judges (both sequence and token-level) for preprocessing.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
inputs: List of dialogue samples (each is list of messages)
|
| 92 |
+
targets: List of target labels (int for sequence, List[Tuple] for token)
|
| 93 |
+
config: Judge configuration with dialogue_format, system_prompt, user_prompt
|
| 94 |
+
is_token_level: Whether this is token-level (uses span wrapping) or sequence-level
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
List of formatted message lists ready for tokenization
|
| 98 |
+
"""
|
| 99 |
+
from sirin.detection.judging.judges.utils.token_level import wrap_spans
|
| 100 |
+
|
| 101 |
+
# Format dialogues using the config's dialogue format
|
| 102 |
+
formatted_input = [
|
| 103 |
+
config.dialogue_format.format(
|
| 104 |
+
**{'question': sample[0]['content'], 'answer': sample[1]['content']}
|
| 105 |
+
)
|
| 106 |
+
for sample in inputs
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
# Build full message lists with system/user/assistant roles
|
| 110 |
+
formatted_messages = []
|
| 111 |
+
for i, sample_text in enumerate(formatted_input):
|
| 112 |
+
# For token-level, wrap spans in the assistant content
|
| 113 |
+
if is_token_level:
|
| 114 |
+
dialogue_sample = inputs[i]
|
| 115 |
+
spans = targets[i]
|
| 116 |
+
assistant_content = wrap_spans(dialogue_sample[1]['content'], spans)
|
| 117 |
+
else:
|
| 118 |
+
# For sequence-level, use the label directly
|
| 119 |
+
assistant_content = str(targets[i])
|
| 120 |
+
|
| 121 |
+
formatted_messages.append([
|
| 122 |
+
{'role': 'system', 'content': config.system_prompt},
|
| 123 |
+
{'role': 'user', 'content': config.user_prompt.format(**{'sample': sample_text})},
|
| 124 |
+
{'role': 'assistant', 'content': assistant_content},
|
| 125 |
+
])
|
| 126 |
+
|
| 127 |
+
return formatted_messages
|