Skip to content

Commit fbce199

Browse files
authored
perf: true batched inference for TransformerDetector (#113)
* perf: true batched inference for TransformerDetector (fixes #23) TransformerDetector.predict_prompt_batch now performs padded batch tokenization and a single model forward pass per configured batch_size, preserving input order and correctly trimming prompt/padding per sample for both tokens and spans. Add strict len(prompts) == len(answers) validation (no silent zip truncation), including in the LLM detector request path. Tests cover uneven sequence lengths, batch_size 1 and >1, output order, tokens, spans, min_confidence filtering, empty input, and mismatch errors; a spy/stub verifies one forward call per transformer batch without downloading a model. * chore: ruff/docstring fixes for #23 * fix: add batch_size default and checks for #23
1 parent 1dd65d1 commit fbce199

4 files changed

Lines changed: 568 additions & 47 deletions

File tree

‎lettucedetect/detectors/llm.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,10 @@ def predict_prompt_batch(
601601
on top of the constructor-level ``min_confidence``. Not applied to token output.
602602
:returns: One result per input pair: spans, or per-token dicts when
603603
``output_format="tokens"``.
604+
:raises ValueError: If ``len(prompts) != len(answers)``.
604605
"""
606+
if len(prompts) != len(answers):
607+
raise ValueError("Number of prompts must match number of answers")
605608
if output_format not in ["tokens", "spans"]:
606609
raise ValueError(
607610
f"LLMDetector doesn't support '{output_format}' format. Use 'tokens' or 'spans'"

‎lettucedetect/detectors/transformer.py‎

Lines changed: 170 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(
3333
lang: Lang = "en",
3434
taxonomy_head: str | None = None,
3535
include_taxonomy: bool | list | dict = True,
36+
batch_size: int = 16,
3637
**tok_kwargs: object,
3738
) -> None:
3839
"""Initialize the transformer detector.
@@ -48,11 +49,16 @@ def __init__(
4849
enter only as text); ``{"categories": {...}, "subcategories": {...}}`` controls both
4950
sets; a list of names selects a subset of the trained categories. Only meaningful
5051
together with ``taxonomy_head``.
52+
:param batch_size: Default number of (prompt, answer) pairs scored together in one pass
53+
by :meth:`predict_prompt_batch`. Must be >= 1.
5154
:param tok_kwargs: Additional keyword arguments for the tokenizer.
5255
"""
5356
if lang not in LANG_TO_PASSAGE:
5457
raise ValueError(f"Invalid language. Choose from {', '.join(LANG_TO_PASSAGE)}")
58+
if batch_size < 1:
59+
raise ValueError("batch_size must be >= 1")
5560
self.lang, self.max_length = lang, max_length
61+
self.batch_size = batch_size
5662
self.tokenizer = AutoTokenizer.from_pretrained(model_path, **tok_kwargs)
5763
self.model = AutoModelForTokenClassification.from_pretrained(model_path, **tok_kwargs)
5864
self.device = device or (
@@ -151,49 +157,43 @@ def _group_passages_into_chunks(
151157
return groups if groups else [context]
152158

153159
# ------------------------------------------------------------------
154-
# Single-chunk prediction (the original _predict logic)
160+
# Shared decoding helper for single-example and batched prediction
155161
# ------------------------------------------------------------------
156162

157-
def _predict_single(self, prompt: str, answer: str, output_format: str) -> list:
158-
"""Run prediction on a single (prompt, answer) pair that fits in ``max_length``.
163+
def _decode_sample(
164+
self,
165+
input_ids: torch.Tensor,
166+
probabilities: torch.Tensor,
167+
offsets: torch.Tensor,
168+
answer_start_token: int,
169+
answer: str,
170+
output_format: str,
171+
) -> list:
172+
"""Decode one sample's model outputs into tokens or spans.
159173
160-
:param prompt: The prompt string.
161-
:param answer: The answer string.
174+
Shared by the single-example and batched prediction paths so both produce
175+
identical output for the same inputs. All tensors must already be trimmed
176+
to this sample's true (unpadded) sequence length.
177+
178+
:param input_ids: Token ids for the sample, shape ``(seq_len,)``.
179+
:param probabilities: Softmax probabilities, shape ``(seq_len, num_labels)``.
180+
:param offsets: Character offset mapping, shape ``(seq_len, 2)``.
181+
:param answer_start_token: Index of the first answer-side token.
182+
:param answer: The original answer string.
162183
:param output_format: ``"tokens"`` or ``"spans"``.
163184
"""
164-
encoding, _, offsets, answer_start_token = HallucinationDataset.prepare_tokenized_input(
165-
self.tokenizer, prompt, answer, self.max_length
166-
)
167-
168-
labels = torch.full_like(encoding.input_ids[0], -100, device=self.device)
169-
labels[answer_start_token:] = 0
170-
171-
encoding = {
172-
key: value.to(self.device)
173-
for key, value in encoding.items()
174-
if key in ["input_ids", "attention_mask", "labels"]
175-
}
176-
177-
with torch.no_grad():
178-
outputs = self.model(**encoding)
179-
logits = outputs.logits
180-
token_preds = torch.argmax(logits, dim=-1)[0]
181-
probabilities = torch.softmax(logits, dim=-1)[0]
182-
183-
token_preds = torch.where(labels == -100, labels, token_preds)
185+
token_preds = torch.argmax(probabilities, dim=-1)
184186

185187
if output_format == "tokens":
186188
token_probs: list[dict] = []
187-
input_ids = encoding["input_ids"][0]
188-
for i, (token, pred, prob) in enumerate(zip(input_ids, token_preds, probabilities)):
189-
if labels[i].item() != -100:
190-
token_probs.append(
191-
{
192-
"token": self.tokenizer.decode([token]),
193-
"pred": pred.item(),
194-
"prob": prob[1].item(),
195-
}
196-
)
189+
for i in range(answer_start_token, input_ids.size(0)):
190+
token_probs.append(
191+
{
192+
"token": self.tokenizer.decode([input_ids[i]]),
193+
"pred": token_preds[i].item(),
194+
"prob": probabilities[i, 1].item(),
195+
}
196+
)
197197
return token_probs
198198

199199
# output_format == "spans"
@@ -206,9 +206,6 @@ def _predict_single(self, prompt: str, answer: str, output_format: str) -> list:
206206
current_span: dict | None = None
207207

208208
for i in range(answer_start_token, token_preds.size(0)):
209-
if labels[i].item() == -100:
210-
continue
211-
212209
token_start, token_end = offsets[i].tolist()
213210
if token_start == token_end:
214211
continue
@@ -221,11 +218,7 @@ def _predict_single(self, prompt: str, answer: str, output_format: str) -> list:
221218

222219
if is_hallucination:
223220
if current_span is None:
224-
current_span = {
225-
"start": rel_start,
226-
"end": rel_end,
227-
"confidence": confidence,
228-
}
221+
current_span = {"start": rel_start, "end": rel_end, "confidence": confidence}
229222
else:
230223
current_span["end"] = rel_end
231224
current_span["confidence"] = max(current_span["confidence"], confidence)
@@ -241,6 +234,106 @@ def _predict_single(self, prompt: str, answer: str, output_format: str) -> list:
241234

242235
return spans
243236

237+
# ------------------------------------------------------------------
238+
# Single-chunk prediction (the original _predict logic)
239+
# ------------------------------------------------------------------
240+
241+
def _predict_single(self, prompt: str, answer: str, output_format: str) -> list:
242+
"""Run prediction on a single (prompt, answer) pair that fits in ``max_length``.
243+
244+
:param prompt: The prompt string.
245+
:param answer: The answer string.
246+
:param output_format: ``"tokens"`` or ``"spans"``.
247+
"""
248+
encoding, _, offsets, answer_start_token = HallucinationDataset.prepare_tokenized_input(
249+
self.tokenizer, prompt, answer, self.max_length
250+
)
251+
252+
encoding = {
253+
key: value.to(self.device)
254+
for key, value in encoding.items()
255+
if key in ["input_ids", "attention_mask", "labels"]
256+
}
257+
258+
with torch.no_grad():
259+
outputs = self.model(**encoding)
260+
probabilities = torch.softmax(outputs.logits, dim=-1)[0]
261+
input_ids = encoding["input_ids"][0]
262+
263+
return self._decode_sample(
264+
input_ids, probabilities, offsets, answer_start_token, answer, output_format
265+
)
266+
267+
# ------------------------------------------------------------------
268+
# Batched prediction with one forward pass for many pairs
269+
# ------------------------------------------------------------------
270+
271+
def _predict_batch(
272+
self, prompts: list[str], answers: list[str], output_format: str
273+
) -> list[list]:
274+
"""Tokenize ``prompts``/``answers`` as one padded batch and score them in one pass.
275+
276+
:param prompts: Prompt strings for this batch (already length-validated
277+
against ``answers`` by the caller).
278+
:param answers: Answer strings for this batch.
279+
:param output_format: ``"tokens"`` or ``"spans"``.
280+
:returns: One prediction list per (prompt, answer) pair, in input order.
281+
"""
282+
if self.tokenizer.padding_side != "right":
283+
raise ValueError(
284+
"TransformerDetector batched inference requires a right-padding tokenizer."
285+
)
286+
287+
batch = self.tokenizer(
288+
prompts,
289+
answers,
290+
truncation="only_first",
291+
max_length=self.max_length,
292+
padding=True,
293+
return_offsets_mapping=True,
294+
return_tensors="pt",
295+
add_special_tokens=True,
296+
)
297+
offsets = batch.pop("offset_mapping")
298+
seq_lens = batch["attention_mask"].sum(dim=1)
299+
300+
model_inputs = {
301+
key: value.to(self.device)
302+
for key, value in batch.items()
303+
if key in ["input_ids", "attention_mask"]
304+
}
305+
306+
with torch.no_grad():
307+
outputs = self.model(**model_inputs)
308+
probabilities = torch.softmax(outputs.logits, dim=-1)
309+
310+
results: list[list] = []
311+
for i, answer in enumerate(answers):
312+
seq_len = int(seq_lens[i].item())
313+
sequence_ids = batch.sequence_ids(i)
314+
answer_start_token = next(
315+
(idx for idx, seq_id in enumerate(sequence_ids) if seq_id == 1),
316+
seq_len - 1,
317+
)
318+
319+
if seq_len >= self.max_length:
320+
logger.warning(
321+
f"predict_prompt_batch: item {i} ({seq_len} tokens) reached "
322+
f"max length ({self.max_length}) and may have been truncated."
323+
)
324+
325+
results.append(
326+
self._decode_sample(
327+
batch["input_ids"][i, :seq_len],
328+
probabilities[i, :seq_len],
329+
offsets[i, :seq_len],
330+
answer_start_token,
331+
answer,
332+
output_format,
333+
)
334+
)
335+
return results
336+
244337
# ------------------------------------------------------------------
245338
# Multi-chunk prediction with max() aggregation
246339
# ------------------------------------------------------------------
@@ -438,16 +531,48 @@ def predict_prompt_batch(
438531
answers: list[str],
439532
output_format: str = "tokens",
440533
min_confidence: float = 0.0,
534+
batch_size: int | None = None,
441535
) -> list:
442536
"""Predict hallucination tokens or spans from the provided prompts and answers.
443537
444538
:param prompts: List of prompt strings.
445539
:param answers: List of answer strings.
446540
:param output_format: ``"tokens"`` or ``"spans"``.
447541
:param min_confidence: Drop ``"spans"`` below this confidence threshold (``[0, 1]``).
542+
:param batch_size: Max number of pairs tokenized and scored together in one forward
543+
pass. Defaults to ``self.batch_size`` or the whole input list if that is also
544+
unset. Every sample in a batch is padded to the longest sequence in that batch,
545+
so memory scales with ``batch_size x longest_sequence_in_batch``.
448546
:returns: List of prediction lists, one per input pair.
547+
:raises ValueError: If ``len(prompts) != len(answers)``.
449548
"""
549+
if len(prompts) != len(answers):
550+
raise ValueError("Number of prompts must match number of answers")
551+
if not prompts:
552+
return []
553+
if output_format not in ("tokens", "spans"):
554+
raise ValueError(
555+
f"TransformerDetector doesn't support '{output_format}' format."
556+
" Use 'tokens' or 'spans'"
557+
)
558+
if batch_size is not None and batch_size < 1:
559+
raise ValueError("batch_size must be >= 1")
560+
self._validate_min_confidence(min_confidence)
561+
562+
effective_batch_size = self.batch_size if batch_size is None else batch_size
563+
564+
results: list[list] = []
565+
for start in range(0, len(prompts), effective_batch_size):
566+
end = start + effective_batch_size
567+
results.extend(
568+
self._predict_batch(prompts[start:end], answers[start:end], output_format)
569+
)
570+
571+
if output_format == "spans" and self.typer is not None:
572+
for i in range(len(results)):
573+
results[i] = self.typer.type_spans(answers[i], prompts[i], results[i])
574+
450575
return [
451-
self.predict_prompt(p, a, output_format, min_confidence)
452-
for p, a in zip(prompts, answers)
576+
self._filter_spans_by_confidence(result, output_format, min_confidence)
577+
for result in results
453578
]

0 commit comments

Comments
 (0)