sysy9292's picture
release: publish validated v2.3 runtime artifact
e962f8e verified
Raw History Blame Contribute Delete
12.5 kB
"""trainable span Head와 분리된 BIO/boundary/constrained decoding."""
from __future__ import annotations
from dataclasses import dataclass
from itertools import product
from typing import Iterable, Sequence
import torch
@dataclass(frozen=True, slots=True)
class DecodedSpan:
sentence_index: int
token_start: int
token_end: int
label: str
score: float
character_start: int | None = None
character_end: int | None = None
@property
def boundary(self) -> tuple[int, int, int]:
return self.sentence_index, self.token_start, self.token_end
@dataclass(frozen=True, slots=True)
class BoundaryDecodeResult:
"""Boundary threshold를 통과한 pair 수와 deterministic 선택 결과."""
spans: tuple[DecodedSpan, ...]
candidate_count: int
class BIOFlatDecoder:
"""BIO argmax를 flat/non-overlap span으로 복원한다."""
def __init__(self, labels: Sequence[str]) -> None:
if not labels or labels[0] != "O":
raise ValueError("BIO labels must start with O")
self.labels = tuple(labels)
def decode(
self,
logits: torch.Tensor,
token_mask: torch.BoolTensor,
) -> list[list[list[DecodedSpan]]]:
if logits.ndim != 4 or token_mask.shape != logits.shape[:3]:
raise ValueError("BIO decoder expects logits [B,S,T,C]")
probabilities = torch.softmax(logits, dim=-1)
ids = probabilities.argmax(dim=-1)
output: list[list[list[DecodedSpan]]] = []
for batch_index in range(logits.shape[0]):
article = []
for sentence_index in range(logits.shape[1]):
spans: list[DecodedSpan] = []
active_label: str | None = None
start = 0
scores: list[float] = []
valid_positions = torch.nonzero(
token_mask[batch_index, sentence_index], as_tuple=False
).flatten().tolist()
for offset in range(len(valid_positions) + 1):
token_index = (
valid_positions[-1] + 1
if offset == len(valid_positions) and valid_positions
else (0 if offset == len(valid_positions) else valid_positions[offset])
)
label = "O" if offset == len(valid_positions) else self.labels[int(ids[batch_index, sentence_index, token_index])]
prefix, _, kind = label.partition("-")
if active_label is not None and (prefix != "I" or kind != active_label):
spans.append(DecodedSpan(sentence_index, start, token_index, active_label, sum(scores) / len(scores)))
active_label, scores = None, []
if prefix == "B" or (prefix == "I" and active_label is None):
active_label, start = kind, token_index
scores = [float(probabilities[batch_index, sentence_index, token_index, int(ids[batch_index, sentence_index, token_index])])]
elif prefix == "I" and kind == active_label:
scores.append(float(probabilities[batch_index, sentence_index, token_index, int(ids[batch_index, sentence_index, token_index])]))
article.append(spans)
output.append(article)
return output
class BoundaryProposalDecoder:
"""독립 start/end probability에서 폭 제한 proposal을 만든다."""
def __init__(self, labels: Sequence[str], threshold: float = 0.5, max_width: int = 96) -> None:
self.labels = tuple(labels)
self.threshold = threshold
self.max_width = max_width
def decode(self, start_logits: torch.Tensor, end_logits: torch.Tensor, mask: torch.BoolTensor) -> list[DecodedSpan]:
return list(self.decode_with_diagnostics(start_logits, end_logits, mask).spans)
def decode_with_diagnostics(
self,
start_logits: torch.Tensor,
end_logits: torch.Tensor,
mask: torch.BoolTensor,
) -> BoundaryDecodeResult:
starts = torch.sigmoid(start_logits).detach()
ends = torch.sigmoid(end_logits).detach()
proposals = []
for sentence in range(start_logits.shape[0]):
for label_index, label in enumerate(self.labels):
start_ids = torch.nonzero((starts[sentence, :, label_index] >= self.threshold) & mask[sentence], as_tuple=False).flatten().tolist()
end_ids = torch.nonzero((ends[sentence, :, label_index] >= self.threshold) & mask[sentence], as_tuple=False).flatten().tolist()
for start, end_inclusive in product(start_ids, end_ids):
if start <= end_inclusive and end_inclusive - start + 1 <= self.max_width:
score = float((starts[sentence, start, label_index] * ends[sentence, end_inclusive, label_index]).sqrt())
proposals.append(DecodedSpan(sentence, start, end_inclusive + 1, label, score))
return BoundaryDecodeResult(tuple(proposals), len(proposals))
class GreedyOneToOneBoundaryDecoder:
"""Confidence 순으로 start/end를 한 번씩만 쓰는 historical-style decoder.
Exact duplicate는 endpoint 1:1 제약으로 제거한다. 서로 다른 endpoint를 사용하는 span의
overlap/nesting은 허용하며, label·문장별 출력 cap으로 무제한 조합을 막는다.
"""
def __init__(
self,
labels: Sequence[str],
threshold: float = 0.5,
max_width: int = 64,
max_outputs_per_label_sentence: int = 4,
) -> None:
if max_width <= 0 or max_outputs_per_label_sentence <= 0:
raise ValueError("Greedy boundary decoder limits must be positive")
self.labels = tuple(labels)
self.threshold = threshold
self.max_width = max_width
self.max_outputs_per_label_sentence = max_outputs_per_label_sentence
def decode(
self,
start_logits: torch.Tensor,
end_logits: torch.Tensor,
mask: torch.BoolTensor,
) -> list[DecodedSpan]:
return list(self.decode_with_diagnostics(start_logits, end_logits, mask).spans)
def decode_with_diagnostics(
self,
start_logits: torch.Tensor,
end_logits: torch.Tensor,
mask: torch.BoolTensor,
) -> BoundaryDecodeResult:
starts = torch.sigmoid(start_logits).detach()
ends = torch.sigmoid(end_logits).detach()
selected: list[DecodedSpan] = []
candidate_count = 0
for sentence in range(start_logits.shape[0]):
for label_index, label in enumerate(self.labels):
start_ids = torch.nonzero(
(starts[sentence, :, label_index] >= self.threshold)
& mask[sentence],
as_tuple=False,
).flatten().tolist()
end_ids = torch.nonzero(
(ends[sentence, :, label_index] >= self.threshold)
& mask[sentence],
as_tuple=False,
).flatten().tolist()
candidates: list[DecodedSpan] = []
for start in start_ids:
for end_inclusive in end_ids:
width = end_inclusive - start + 1
if 1 <= width <= self.max_width:
score = float(
(
starts[sentence, start, label_index]
* ends[sentence, end_inclusive, label_index]
).sqrt()
)
candidates.append(
DecodedSpan(
sentence,
start,
end_inclusive + 1,
label,
score,
)
)
candidate_count += len(candidates)
used_starts: set[int] = set()
used_ends: set[int] = set()
for candidate in sorted(
candidates,
key=lambda item: (
-item.score,
item.token_start,
item.token_end,
),
):
end_inclusive = candidate.token_end - 1
if (
candidate.token_start in used_starts
or end_inclusive in used_ends
):
continue
selected.append(candidate)
used_starts.add(candidate.token_start)
used_ends.add(end_inclusive)
if (
len(used_starts)
>= self.max_outputs_per_label_sentence
):
break
return BoundaryDecodeResult(tuple(selected), candidate_count)
@dataclass(frozen=True, slots=True)
class SemanticConstraintConfig:
threshold: float = 0.5
max_outputs_per_sentence: int = 12
allow_cross_label_exact_boundary: bool = True
allow_nested: bool = True
containment_margin: float = 0.08
overlap_margin: float = 0.05
class SemanticConstraintDecoder:
"""confidence 순으로 prefix/suffix/merged proposition 중복을 제한한다."""
def __init__(self, config: SemanticConstraintConfig | None = None) -> None:
self.config = config or SemanticConstraintConfig()
def decode(self, candidates: Iterable[DecodedSpan]) -> list[DecodedSpan]:
selected: list[DecodedSpan] = []
counts: dict[int, int] = {}
for candidate in sorted(candidates, key=lambda item: (-item.score, item.token_start, item.token_end, item.label)):
if candidate.score < self.config.threshold:
continue
if counts.get(candidate.sentence_index, 0) >= self.config.max_outputs_per_sentence:
continue
if any(self._conflicts(candidate, accepted) for accepted in selected):
continue
selected.append(candidate)
counts[candidate.sentence_index] = counts.get(candidate.sentence_index, 0) + 1
return sorted(selected, key=lambda item: (item.sentence_index, item.token_start, item.token_end, item.label))
def _conflicts(self, candidate: DecodedSpan, accepted: DecodedSpan) -> bool:
if candidate.sentence_index != accepted.sentence_index:
return False
if candidate.boundary == accepted.boundary:
return not (
self.config.allow_cross_label_exact_boundary and candidate.label != accepted.label
)
overlap = max(candidate.token_start, accepted.token_start) < min(candidate.token_end, accepted.token_end)
if not overlap:
return False
candidate_contains = candidate.token_start <= accepted.token_start and accepted.token_end <= candidate.token_end
accepted_contains = accepted.token_start <= candidate.token_start and candidate.token_end <= accepted.token_end
if candidate_contains or accepted_contains:
if self.config.allow_nested and candidate.label != accepted.label:
return False
return candidate.score + self.config.containment_margin < accepted.score
return candidate.score <= accepted.score + self.config.overlap_margin
def attach_character_offsets(
spans: Iterable[DecodedSpan],
token_offsets: torch.LongTensor,
sentence_offsets: torch.LongTensor,
) -> list[DecodedSpan]:
output = []
for span in spans:
local_start = int(token_offsets[span.sentence_index, span.token_start, 0])
local_end = int(token_offsets[span.sentence_index, span.token_end - 1, 1])
article_start = int(sentence_offsets[span.sentence_index, 0]) + local_start
article_end = int(sentence_offsets[span.sentence_index, 0]) + local_end
output.append(
DecodedSpan(
span.sentence_index,
span.token_start,
span.token_end,
span.label,
span.score,
article_start,
article_end,
)
)
return output