parchiev commited on
Commit
6c98539
·
verified ·
1 Parent(s): 3fd0904

Convert static replay to Streamlit app (hosted profile)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +5 -0
  2. .streamlit/config.toml +20 -0
  3. README.md +14 -7
  4. index.html +0 -144
  5. pyproject.toml +21 -0
  6. requirements.txt +15 -0
  7. sirin/__init__.py +0 -0
  8. sirin/classification/__init__.py +1 -0
  9. sirin/classification/linear.py +335 -0
  10. sirin/configs/detector/probing.yaml +8 -0
  11. sirin/configs/feature_processor/hiddens.yaml +7 -0
  12. sirin/configs/model_adapter/default.yaml +8 -0
  13. sirin/configs/model_adapter/hf_large_model.yaml +17 -0
  14. sirin/configs/model_adapter/hf_with_batching.yaml +18 -0
  15. sirin/configs/model_adapter/openai_with_parallel.yaml +16 -0
  16. sirin/configs/model_adapter/openrouter.yaml +15 -0
  17. sirin/configs/model_adapter/vllm_with_batching.yaml +18 -0
  18. sirin/configs/pipeline/probing.yaml +23 -0
  19. sirin/configs/token_locator_config/default.yaml +1 -0
  20. sirin/configs/train.yaml +24 -0
  21. sirin/configs/train_args/default.yaml +10 -0
  22. sirin/definitions/__init__.py +3 -0
  23. sirin/definitions/constants.py +20 -0
  24. sirin/definitions/enums.py +204 -0
  25. sirin/definitions/types.py +35 -0
  26. sirin/detection/__init__.py +0 -0
  27. sirin/detection/_lm_polygraph_compat.py +168 -0
  28. sirin/detection/approximators/__init__.py +18 -0
  29. sirin/detection/approximators/base.py +13 -0
  30. sirin/detection/approximators/sep.py +224 -0
  31. sirin/detection/base.py +497 -0
  32. sirin/detection/judging/__init__.py +40 -0
  33. sirin/detection/judging/judges/__init__.py +4 -0
  34. sirin/detection/judging/judges/base.py +292 -0
  35. sirin/detection/judging/judges/claim/__init__.py +3 -0
  36. sirin/detection/judging/judges/claim/decoder.py +201 -0
  37. sirin/detection/judging/judges/claim/encoder.py +169 -0
  38. sirin/detection/judging/judges/claim/openai.py +134 -0
  39. sirin/detection/judging/judges/sequence/__init__.py +3 -0
  40. sirin/detection/judging/judges/sequence/decoder.py +139 -0
  41. sirin/detection/judging/judges/sequence/encoder.py +108 -0
  42. sirin/detection/judging/judges/sequence/openai.py +99 -0
  43. sirin/detection/judging/judges/token/__init__.py +3 -0
  44. sirin/detection/judging/judges/token/decoder.py +186 -0
  45. sirin/detection/judging/judges/token/encoder.py +131 -0
  46. sirin/detection/judging/judges/token/openai.py +139 -0
  47. sirin/detection/judging/judges/utils/__init__.py +23 -0
  48. sirin/detection/judging/judges/utils/decoder.py +67 -0
  49. sirin/detection/judging/judges/utils/metrics.py +106 -0
  50. 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 UI Demo
3
  emoji: 🔎
4
  colorFrom: purple
5
  colorTo: pink
6
- sdk: static
 
 
 
 
7
  tags:
8
  - hallucination-detection
9
  - answerability
10
  - longmemeval
11
  - demo
12
- short_description: Interactive replay of verified SIRIN inspection artifacts
13
  ---
14
 
15
- # SIRIN UI Demo
16
 
17
- An interactive, offline replay of verified SIRIN artifacts for contextual-inconsistency inspection.
 
 
18
 
19
- Choose a recorded LongMemEval case to inspect decisive evidence, the recorded response, its uncertainty or sequence score visualization, and run provenance. This public demo does not load model weights, call external APIs, or accept user data.
 
20
 
21
- For the complete project and live UI workflow, see the [SIRIN repository](https://github.com/sb-ai-lab/SIRIN).
 
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 &amp; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[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