RadGraph-IT 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- radgraph_it-1.0.0.dist-info/METADATA +159 -0
- radgraph_it-1.0.0.dist-info/RECORD +39 -0
- radgraph_it-1.0.0.dist-info/WHEEL +4 -0
- radgraph_it-1.0.0.dist-info/entry_points.txt +2 -0
- radgraph_it-1.0.0.dist-info/licenses/LICENSE +23 -0
- radgraph_it-1.0.0.dist-info/licenses/LICENSES/Third-Party.md +41 -0
- radgraphit/__init__.py +47 -0
- radgraphit/api.py +168 -0
- radgraphit/artifacts/__init__.py +5 -0
- radgraphit/artifacts/manifest.py +290 -0
- radgraphit/artifacts/registry.py +24 -0
- radgraphit/artifacts/resolver.py +200 -0
- radgraphit/cli.py +131 -0
- radgraphit/core/__init__.py +1 -0
- radgraphit/core/errors.py +46 -0
- radgraphit/core/models.py +116 -0
- radgraphit/inference/__init__.py +5 -0
- radgraphit/inference/backends/__init__.py +1 -0
- radgraphit/inference/backends/base.py +13 -0
- radgraphit/inference/backends/dygie_v2/__init__.py +11 -0
- radgraphit/inference/backends/dygie_v2/backend.py +32 -0
- radgraphit/inference/backends/dygie_v2/device.py +46 -0
- radgraphit/inference/backends/dygie_v2/feedforward.py +25 -0
- radgraphit/inference/backends/dygie_v2/loader.py +165 -0
- radgraphit/inference/backends/dygie_v2/model.py +101 -0
- radgraphit/inference/backends/dygie_v2/ner_head.py +75 -0
- radgraphit/inference/backends/dygie_v2/pruner.py +27 -0
- radgraphit/inference/backends/dygie_v2/relation_head.py +147 -0
- radgraphit/inference/backends/dygie_v2/span_extractor.py +51 -0
- radgraphit/inference/backends/dygie_v2/spans.py +20 -0
- radgraphit/inference/backends/dygie_v2/tokenizer_embedder.py +123 -0
- radgraphit/inference/backends/dygie_v2/transformer_block.py +62 -0
- radgraphit/inference/backends/dygie_v2/vocab.py +123 -0
- radgraphit/inference/decoder.py +53 -0
- radgraphit/inference/service.py +80 -0
- radgraphit/inference/tokenizer.py +60 -0
- radgraphit/py.typed +0 -0
- radgraphit/serialization/__init__.py +15 -0
- radgraphit/serialization/radgraph_xl.py +135 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Relation head — inference-only port of ``training_v2/src/dygie/relation_head.py``.
|
|
2
|
+
|
|
3
|
+
Prune to top-K spans, score every ordered pair, decode. The parameter-bearing structure
|
|
4
|
+
(``pruners`` / ``span_transformers`` / ``relation_feedforwards`` / ``relation_scorers`` ModuleDicts,
|
|
5
|
+
the null-column trick, and the optional ``span_pooling`` / ``transformer_params`` /
|
|
6
|
+
``relation_context`` / ``relation_feedforward_params`` variants) is preserved exactly so the state
|
|
7
|
+
dict matches the checkpoint. Loss and metrics are dropped; decode returns plain
|
|
8
|
+
``(s1, e1, s2, e2, label, raw_score, softmax_score)`` tuples.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
from torch import nn
|
|
17
|
+
|
|
18
|
+
from .feedforward import FeedForward
|
|
19
|
+
from .pruner import Pruner
|
|
20
|
+
from .transformer_block import TransformerBlock
|
|
21
|
+
from .vocab import Vocabulary
|
|
22
|
+
|
|
23
|
+
RelationTuple = tuple[int, int, int, int, str, float, float]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RelationHead(nn.Module):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
vocab: Vocabulary,
|
|
30
|
+
span_emb_dim: int,
|
|
31
|
+
word_emb_dim: int,
|
|
32
|
+
feedforward_params: dict,
|
|
33
|
+
spans_per_word: float,
|
|
34
|
+
transformer_params: dict | None = None,
|
|
35
|
+
use_between_context: bool = False,
|
|
36
|
+
relation_feedforward_params: dict | None = None,
|
|
37
|
+
):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.vocab = vocab
|
|
40
|
+
self.spans_per_word = spans_per_word
|
|
41
|
+
self.namespaces = [ns for ns in vocab.get_namespaces() if ns.endswith("relation_labels")]
|
|
42
|
+
self.n_labels = {ns: vocab.get_vocab_size(ns) for ns in self.namespaces}
|
|
43
|
+
self.use_transformer = transformer_params is not None
|
|
44
|
+
self.use_between_context = use_between_context
|
|
45
|
+
between_dim = word_emb_dim if use_between_context else 0
|
|
46
|
+
relation_feedforward_params = relation_feedforward_params or feedforward_params
|
|
47
|
+
|
|
48
|
+
self.pruners = nn.ModuleDict()
|
|
49
|
+
self.span_transformers = nn.ModuleDict()
|
|
50
|
+
self.relation_feedforwards = nn.ModuleDict()
|
|
51
|
+
self.relation_scorers = nn.ModuleDict()
|
|
52
|
+
|
|
53
|
+
for ns in self.namespaces:
|
|
54
|
+
mention_ff = FeedForward(
|
|
55
|
+
span_emb_dim, feedforward_params["hidden_dims"], feedforward_params["dropout"]
|
|
56
|
+
)
|
|
57
|
+
mention_scorer = nn.Sequential(mention_ff, nn.Linear(mention_ff.output_dim, 1))
|
|
58
|
+
self.pruners[ns] = Pruner(mention_scorer)
|
|
59
|
+
|
|
60
|
+
if self.use_transformer:
|
|
61
|
+
assert transformer_params is not None
|
|
62
|
+
block = TransformerBlock(span_emb_dim, **transformer_params)
|
|
63
|
+
self.span_transformers[ns] = block
|
|
64
|
+
self.relation_scorers[ns] = nn.Linear(
|
|
65
|
+
3 * block.output_dim + between_dim, self.n_labels[ns] - 1
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
relation_ff = FeedForward(
|
|
69
|
+
3 * span_emb_dim + between_dim,
|
|
70
|
+
relation_feedforward_params["hidden_dims"],
|
|
71
|
+
relation_feedforward_params["dropout"],
|
|
72
|
+
)
|
|
73
|
+
self.relation_feedforwards[ns] = relation_ff
|
|
74
|
+
self.relation_scorers[ns] = nn.Linear(relation_ff.output_dim, self.n_labels[ns] - 1)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _pair_embeddings(top_embeddings: torch.Tensor) -> torch.Tensor:
|
|
78
|
+
k = top_embeddings.size(0)
|
|
79
|
+
e1 = top_embeddings.unsqueeze(1).expand(k, k, -1)
|
|
80
|
+
e2 = top_embeddings.unsqueeze(0).expand(k, k, -1)
|
|
81
|
+
return torch.cat([e1, e2, e1 * e2], dim=-1)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _between_context(
|
|
85
|
+
word_embeddings: torch.Tensor, top_spans: list[tuple[int, int]]
|
|
86
|
+
) -> torch.Tensor:
|
|
87
|
+
device, dtype = word_embeddings.device, word_embeddings.dtype
|
|
88
|
+
starts = torch.tensor([s[0] for s in top_spans], dtype=torch.long, device=device)
|
|
89
|
+
ends = torch.tensor([s[1] for s in top_spans], dtype=torch.long, device=device)
|
|
90
|
+
|
|
91
|
+
zero_row = word_embeddings.new_zeros(1, word_embeddings.size(-1))
|
|
92
|
+
prefix = torch.cat([zero_row, word_embeddings.cumsum(dim=0)], dim=0) # (num_words + 1, dim)
|
|
93
|
+
|
|
94
|
+
low = torch.minimum(ends.unsqueeze(1), ends.unsqueeze(0))
|
|
95
|
+
high = torch.maximum(starts.unsqueeze(1), starts.unsqueeze(0))
|
|
96
|
+
count = (high - low - 1).clamp(min=0)
|
|
97
|
+
|
|
98
|
+
seg_sum = prefix[high] - prefix[low + 1] # (K, K, dim)
|
|
99
|
+
mean = seg_sum / count.clamp(min=1).unsqueeze(-1).to(dtype)
|
|
100
|
+
return torch.where((count > 0).unsqueeze(-1), mean, seg_sum.new_zeros(()))
|
|
101
|
+
|
|
102
|
+
def predict(
|
|
103
|
+
self,
|
|
104
|
+
dataset: str,
|
|
105
|
+
spans: list[tuple[int, int]],
|
|
106
|
+
span_embeddings: torch.Tensor,
|
|
107
|
+
num_words: int,
|
|
108
|
+
word_embeddings: torch.Tensor,
|
|
109
|
+
) -> list[RelationTuple]:
|
|
110
|
+
ns = f"{dataset}__relation_labels"
|
|
111
|
+
num_to_keep = math.ceil(num_words * self.spans_per_word)
|
|
112
|
+
|
|
113
|
+
top_embeddings, top_indices, top_scores = self.pruners[ns](span_embeddings, num_to_keep)
|
|
114
|
+
top_spans = [spans[i] for i in top_indices.tolist()]
|
|
115
|
+
|
|
116
|
+
if self.use_transformer:
|
|
117
|
+
starts = torch.tensor(
|
|
118
|
+
[s[0] for s in top_spans], dtype=torch.long, device=span_embeddings.device
|
|
119
|
+
)
|
|
120
|
+
top_embeddings = self.span_transformers[ns](top_embeddings, starts)
|
|
121
|
+
|
|
122
|
+
pair_embeddings = self._pair_embeddings(top_embeddings)
|
|
123
|
+
if self.use_between_context:
|
|
124
|
+
between = self._between_context(word_embeddings, top_spans)
|
|
125
|
+
pair_embeddings = torch.cat([pair_embeddings, between], dim=-1)
|
|
126
|
+
|
|
127
|
+
k = top_embeddings.size(0)
|
|
128
|
+
if self.use_transformer:
|
|
129
|
+
scores = self.relation_scorers[ns](pair_embeddings.view(k * k, -1)).view(k, k, -1)
|
|
130
|
+
else:
|
|
131
|
+
projected = self.relation_feedforwards[ns](pair_embeddings.view(k * k, -1))
|
|
132
|
+
scores = self.relation_scorers[ns](projected).view(k, k, -1)
|
|
133
|
+
scores = scores + top_scores.view(k, 1, 1) + top_scores.view(1, k, 1)
|
|
134
|
+
dummy = scores.new_zeros(k, k, 1)
|
|
135
|
+
scores = torch.cat([dummy, scores], dim=-1) # (K, K, n_labels), null at index 0
|
|
136
|
+
|
|
137
|
+
raw, predicted = scores.max(dim=-1)
|
|
138
|
+
soft, _ = torch.softmax(scores, dim=-1).max(dim=-1)
|
|
139
|
+
|
|
140
|
+
predictions: list[RelationTuple] = []
|
|
141
|
+
for i, j in (predicted != 0).nonzero(as_tuple=False).tolist():
|
|
142
|
+
label = self.vocab.get_token_from_index(int(predicted[i, j]), ns)
|
|
143
|
+
(s1, e1), (s2, e2) = top_spans[i], top_spans[j]
|
|
144
|
+
predictions.append(
|
|
145
|
+
(int(s1), int(e1), int(s2), int(e2), label, float(raw[i, j]), float(soft[i, j]))
|
|
146
|
+
)
|
|
147
|
+
return predictions
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Endpoint span extractor — faithful port of ``training_v2/src/dygie/span_extractor.py``.
|
|
2
|
+
|
|
3
|
+
concat(start emb, end emb, [mean-pooled interior emb if ``mean_pool``], width emb). The
|
|
4
|
+
``width_embedding`` submodule name is preserved for state-dict compatibility. Inference runs the
|
|
5
|
+
batch_size=1 path (no batch dim, no span masking), exactly as upstream.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
from torch import nn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EndpointSpanExtractor(nn.Module):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
input_dim: int,
|
|
18
|
+
num_width_embeddings: int,
|
|
19
|
+
span_width_embedding_dim: int,
|
|
20
|
+
mean_pool: bool = False,
|
|
21
|
+
):
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.width_embedding = nn.Embedding(num_width_embeddings, span_width_embedding_dim)
|
|
24
|
+
self.mean_pool = mean_pool
|
|
25
|
+
self.output_dim = (3 if mean_pool else 2) * input_dim + span_width_embedding_dim
|
|
26
|
+
|
|
27
|
+
def get_output_dim(self) -> int:
|
|
28
|
+
return self.output_dim
|
|
29
|
+
|
|
30
|
+
def forward(self, word_embeddings: torch.Tensor, spans: torch.LongTensor) -> torch.Tensor:
|
|
31
|
+
"""word_embeddings: (num_words, input_dim). spans: (num_spans, 2) inclusive [start, end]."""
|
|
32
|
+
starts, ends = spans[:, 0], spans[:, 1]
|
|
33
|
+
start_emb = word_embeddings[starts]
|
|
34
|
+
end_emb = word_embeddings[ends]
|
|
35
|
+
width_emb = self.width_embedding(ends - starts) # 0-indexed width (widths 1..N -> 0..N-1)
|
|
36
|
+
if not self.mean_pool:
|
|
37
|
+
return torch.cat([start_emb, end_emb, width_emb], dim=-1)
|
|
38
|
+
mean_emb = self._interior_mean(word_embeddings, starts, ends)
|
|
39
|
+
return torch.cat([start_emb, end_emb, mean_emb, width_emb], dim=-1)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _interior_mean(
|
|
43
|
+
word_embeddings: torch.Tensor, starts: torch.Tensor, ends: torch.Tensor
|
|
44
|
+
) -> torch.Tensor:
|
|
45
|
+
widths = ends - starts + 1 # (num_spans,)
|
|
46
|
+
max_width = int(widths.max().item())
|
|
47
|
+
offsets = torch.arange(max_width, device=word_embeddings.device).unsqueeze(0) # (1, W)
|
|
48
|
+
mask = offsets < widths.unsqueeze(1) # (num_spans, W)
|
|
49
|
+
idx = (starts.unsqueeze(1) + offsets).clamp(max=word_embeddings.size(0) - 1)
|
|
50
|
+
gathered = word_embeddings[idx] * mask.unsqueeze(-1) # (num_spans, W, input_dim)
|
|
51
|
+
return gathered.sum(dim=1) / widths.unsqueeze(1).to(word_embeddings.dtype)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Candidate span enumeration — the inference-relevant half of ``training_v2/src/dygie/dataset.py``.
|
|
2
|
+
|
|
3
|
+
One document is a single "sentence" spanning the whole report (v2 invariant), so enumeration is the
|
|
4
|
+
only piece of the dataset reader inference needs: all inclusive ``(start, end)`` spans of width
|
|
5
|
+
1..``max_span_width``, ordered by ``(start, end)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def enumerate_spans(
|
|
12
|
+
words: tuple[str, ...] | list[str], max_span_width: int
|
|
13
|
+
) -> list[tuple[int, int]]:
|
|
14
|
+
"""All inclusive (start, end) spans of width 1..max_span_width, ordered by (start, end)."""
|
|
15
|
+
n = len(words)
|
|
16
|
+
spans: list[tuple[int, int]] = []
|
|
17
|
+
for start in range(n):
|
|
18
|
+
for end in range(start, min(start + max_span_width, n)):
|
|
19
|
+
spans.append((start, end))
|
|
20
|
+
return spans
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Word-level embeddings from a subword encoder — faithful port of
|
|
2
|
+
``training_v2/src/dygie/tokenizer_embedder.py``.
|
|
3
|
+
|
|
4
|
+
Tokenize each word into subwords, run the transformer, mean-pool subwords back to one vector per
|
|
5
|
+
word. Long documents are split into non-overlapping, word-aligned chunks of at most ``max_length``
|
|
6
|
+
transformer positions ("context folding"), so a word's subwords are always encoded together.
|
|
7
|
+
|
|
8
|
+
The ``encoder`` submodule is built from the pinned encoder configuration without downloading its
|
|
9
|
+
pretrained weights: every encoder parameter is subsequently populated by the verified RadGraphIT
|
|
10
|
+
checkpoint through ``load_state_dict(strict=True)``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
from torch import nn
|
|
19
|
+
from transformers import AutoConfig, AutoModel, AutoTokenizer
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("radgraphit")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MismatchedEmbedder(nn.Module):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model_name: str,
|
|
28
|
+
max_length: int,
|
|
29
|
+
train_parameters: bool = True,
|
|
30
|
+
*,
|
|
31
|
+
revision: str | None = None,
|
|
32
|
+
):
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name, revision=revision)
|
|
35
|
+
encoder_config = AutoConfig.from_pretrained(model_name, revision=revision)
|
|
36
|
+
self.encoder = AutoModel.from_config(encoder_config) # type: ignore[no-untyped-call]
|
|
37
|
+
self.max_length = max_length
|
|
38
|
+
self.output_dim = self.encoder.config.hidden_size
|
|
39
|
+
if not train_parameters:
|
|
40
|
+
for p in self.encoder.parameters():
|
|
41
|
+
p.requires_grad = False
|
|
42
|
+
|
|
43
|
+
num_specials = self.tokenizer.num_special_tokens_to_add(pair=False)
|
|
44
|
+
budget = max_length - num_specials
|
|
45
|
+
if budget <= 0:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"max_length={max_length} too small for {model_name}'s "
|
|
48
|
+
f"{num_specials} special tokens"
|
|
49
|
+
)
|
|
50
|
+
self._budget = budget
|
|
51
|
+
|
|
52
|
+
def get_output_dim(self) -> int:
|
|
53
|
+
return self.output_dim
|
|
54
|
+
|
|
55
|
+
def _word_subword_counts(self, words: list[str]) -> list[int]:
|
|
56
|
+
enc = self.tokenizer(words, is_split_into_words=True, add_special_tokens=False)
|
|
57
|
+
counts = [0] * len(words)
|
|
58
|
+
for w in enc.word_ids():
|
|
59
|
+
if w is not None:
|
|
60
|
+
counts[w] += 1
|
|
61
|
+
return counts
|
|
62
|
+
|
|
63
|
+
def _pack_chunks(self, counts: list[int]) -> list[list[int]]:
|
|
64
|
+
"""Greedily group word indices into chunks whose total subword count fits the budget."""
|
|
65
|
+
chunks: list[list[int]] = []
|
|
66
|
+
current: list[int] = []
|
|
67
|
+
current_len = 0
|
|
68
|
+
for w, n in enumerate(counts):
|
|
69
|
+
n = max(n, 1)
|
|
70
|
+
if n > self._budget:
|
|
71
|
+
logger.warning(
|
|
72
|
+
"word %d has %d subwords > budget %d; it will be truncated by the tokenizer",
|
|
73
|
+
w,
|
|
74
|
+
n,
|
|
75
|
+
self._budget,
|
|
76
|
+
)
|
|
77
|
+
if current and current_len + n > self._budget:
|
|
78
|
+
chunks.append(current)
|
|
79
|
+
current, current_len = [], 0
|
|
80
|
+
current.append(w)
|
|
81
|
+
current_len += n
|
|
82
|
+
if current:
|
|
83
|
+
chunks.append(current)
|
|
84
|
+
return chunks
|
|
85
|
+
|
|
86
|
+
def forward(self, words: list[str]) -> torch.Tensor:
|
|
87
|
+
"""Returns (num_words, hidden_size)."""
|
|
88
|
+
device = next(self.encoder.parameters()).device
|
|
89
|
+
counts = self._word_subword_counts(words)
|
|
90
|
+
chunks = self._pack_chunks(counts)
|
|
91
|
+
|
|
92
|
+
word_embeddings: list[torch.Tensor | None] = [None] * len(words)
|
|
93
|
+
hidden: torch.Tensor | None = None
|
|
94
|
+
for chunk_word_ixs in chunks:
|
|
95
|
+
chunk_words = [words[i] for i in chunk_word_ixs]
|
|
96
|
+
enc = self.tokenizer(
|
|
97
|
+
chunk_words,
|
|
98
|
+
is_split_into_words=True,
|
|
99
|
+
add_special_tokens=True,
|
|
100
|
+
truncation=True,
|
|
101
|
+
max_length=self.max_length,
|
|
102
|
+
return_tensors="pt",
|
|
103
|
+
)
|
|
104
|
+
input_ids = enc["input_ids"].to(device)
|
|
105
|
+
attention_mask = enc["attention_mask"].to(device)
|
|
106
|
+
hidden = self.encoder(
|
|
107
|
+
input_ids=input_ids, attention_mask=attention_mask
|
|
108
|
+
).last_hidden_state[0]
|
|
109
|
+
|
|
110
|
+
word_ids = enc.word_ids(batch_index=0)
|
|
111
|
+
positions_by_word: dict[int, list[int]] = {}
|
|
112
|
+
for pos, w in enumerate(word_ids):
|
|
113
|
+
if w is not None:
|
|
114
|
+
positions_by_word.setdefault(w, []).append(pos)
|
|
115
|
+
for local_w, positions in positions_by_word.items():
|
|
116
|
+
word_embeddings[chunk_word_ixs[local_w]] = hidden[positions].mean(dim=0)
|
|
117
|
+
|
|
118
|
+
for i, emb in enumerate(word_embeddings):
|
|
119
|
+
if emb is None: # word tokenized to zero subwords (rare; e.g. an unknown char)
|
|
120
|
+
assert hidden is not None # at least one chunk always runs for a non-empty report
|
|
121
|
+
word_embeddings[i] = hidden.new_zeros(self.output_dim)
|
|
122
|
+
|
|
123
|
+
return torch.stack(word_embeddings, dim=0) # type: ignore[arg-type]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Span-attention scorer body — faithful port of ``training_v2/src/dygie/transformer_block.py``.
|
|
2
|
+
|
|
3
|
+
Used by the NER / relation heads in the ``medbit_transformer`` arm in place of the plain
|
|
4
|
+
``FeedForward`` scorer. Submodule names (``input_proj``, ``encoder``) are preserved for state-dict
|
|
5
|
+
compatibility. Spans are the "sequence"; a sinusoidal positional encoding keyed on each span's
|
|
6
|
+
start-word index gives attention a notion of span order/distance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
from torch import nn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sinusoidal_position_encoding(positions: torch.Tensor, d_model: int) -> torch.Tensor:
|
|
18
|
+
"""positions: (N,) int64 span start indices. Returns (N, d_model)."""
|
|
19
|
+
div_term = torch.exp(
|
|
20
|
+
torch.arange(0, d_model, 2, device=positions.device, dtype=torch.float32)
|
|
21
|
+
* (-math.log(10000.0) / d_model)
|
|
22
|
+
)
|
|
23
|
+
angles = positions.unsqueeze(1).to(torch.float32) * div_term.unsqueeze(0) # (N, d_model/2)
|
|
24
|
+
pe = torch.zeros(positions.size(0), d_model, device=positions.device)
|
|
25
|
+
pe[:, 0::2] = torch.sin(angles)
|
|
26
|
+
pe[:, 1::2] = torch.cos(angles[:, : pe[:, 1::2].size(1)])
|
|
27
|
+
return pe
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TransformerBlock(nn.Module):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
input_dim: int,
|
|
34
|
+
d_model: int,
|
|
35
|
+
nhead: int,
|
|
36
|
+
num_layers: int,
|
|
37
|
+
dim_feedforward: int,
|
|
38
|
+
dropout: float,
|
|
39
|
+
activation: str = "gelu",
|
|
40
|
+
):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.input_proj = nn.Linear(input_dim, d_model) if input_dim != d_model else nn.Identity()
|
|
43
|
+
layer = nn.TransformerEncoderLayer(
|
|
44
|
+
d_model=d_model,
|
|
45
|
+
nhead=nhead,
|
|
46
|
+
dim_feedforward=dim_feedforward,
|
|
47
|
+
dropout=dropout,
|
|
48
|
+
activation=activation,
|
|
49
|
+
norm_first=True,
|
|
50
|
+
batch_first=True,
|
|
51
|
+
)
|
|
52
|
+
self.encoder = nn.TransformerEncoder(
|
|
53
|
+
layer, num_layers=num_layers, enable_nested_tensor=False
|
|
54
|
+
)
|
|
55
|
+
self.d_model = d_model
|
|
56
|
+
self.output_dim = d_model
|
|
57
|
+
|
|
58
|
+
def forward(self, span_embeddings: torch.Tensor, start_positions: torch.Tensor) -> torch.Tensor:
|
|
59
|
+
x = self.input_proj(span_embeddings) + sinusoidal_position_encoding(
|
|
60
|
+
start_positions, self.d_model
|
|
61
|
+
)
|
|
62
|
+
return self.encoder(x.unsqueeze(0)).squeeze(0)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Minimal label vocabulary — inference-only port of ``training_v2/src/dygie/vocab.py``.
|
|
2
|
+
|
|
3
|
+
Per-namespace string<->int maps with the null label ``""`` pinned to index 0 (so "argmax == 0"
|
|
4
|
+
means "no label"). Namespaces are ``"{dataset}__ner_labels"`` / ``"{dataset}__relation_labels"``;
|
|
5
|
+
the dataset prefix is *derived from the saved vocab*, never hard-coded, so the label schema is
|
|
6
|
+
whatever the checkpoint was trained with (the current v2 artifact: 6 NER classes, 3 relations).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ....core.errors import CheckpointError
|
|
15
|
+
|
|
16
|
+
NULL_LABEL = ""
|
|
17
|
+
NER_SUFFIX = "__ner_labels"
|
|
18
|
+
RELATION_SUFFIX = "__relation_labels"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Vocabulary:
|
|
22
|
+
"""String<->index maps per namespace, loaded from the checkpoint's ``vocab.json``."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._token_to_index: dict[str, dict[str, int]] = {}
|
|
26
|
+
self._index_to_token: dict[str, dict[int, str]] = {}
|
|
27
|
+
|
|
28
|
+
def add_namespace(self, namespace: str) -> None:
|
|
29
|
+
if namespace not in self._token_to_index:
|
|
30
|
+
self._token_to_index[namespace] = {NULL_LABEL: 0}
|
|
31
|
+
self._index_to_token[namespace] = {0: NULL_LABEL}
|
|
32
|
+
|
|
33
|
+
def add_token(self, token: str, namespace: str) -> int:
|
|
34
|
+
self.add_namespace(namespace)
|
|
35
|
+
t2i = self._token_to_index[namespace]
|
|
36
|
+
if token not in t2i:
|
|
37
|
+
idx = len(t2i)
|
|
38
|
+
t2i[token] = idx
|
|
39
|
+
self._index_to_token[namespace][idx] = token
|
|
40
|
+
return t2i[token]
|
|
41
|
+
|
|
42
|
+
def get_token_index(self, token: str, namespace: str) -> int:
|
|
43
|
+
return self._token_to_index[namespace][token]
|
|
44
|
+
|
|
45
|
+
def get_token_from_index(self, index: int, namespace: str) -> str:
|
|
46
|
+
return self._index_to_token[namespace][index]
|
|
47
|
+
|
|
48
|
+
def get_vocab_size(self, namespace: str) -> int:
|
|
49
|
+
return len(self._token_to_index[namespace])
|
|
50
|
+
|
|
51
|
+
def get_namespaces(self) -> list[str]:
|
|
52
|
+
return list(self._token_to_index.keys())
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def load(cls, path: str | Path) -> Vocabulary:
|
|
56
|
+
"""Load the exact label<->index mapping saved next to the checkpoint."""
|
|
57
|
+
vocab_path = Path(path)
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(vocab_path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
61
|
+
raise CheckpointError(
|
|
62
|
+
f"Could not read a valid JSON vocabulary at {vocab_path}: {exc}"
|
|
63
|
+
) from exc
|
|
64
|
+
if not isinstance(data, dict) or not data:
|
|
65
|
+
raise CheckpointError(f"vocab.json at {vocab_path} must contain a non-empty object.")
|
|
66
|
+
|
|
67
|
+
vocab = cls()
|
|
68
|
+
for namespace, t2i in data.items():
|
|
69
|
+
if not isinstance(namespace, str) or not namespace:
|
|
70
|
+
raise CheckpointError("Every vocab namespace must be a non-empty string.")
|
|
71
|
+
if not isinstance(t2i, dict) or not t2i:
|
|
72
|
+
raise CheckpointError(f"Vocab namespace {namespace!r} must be a non-empty object.")
|
|
73
|
+
if t2i.get(NULL_LABEL) != 0:
|
|
74
|
+
raise CheckpointError(
|
|
75
|
+
f"Vocab namespace {namespace!r} must map the null label '' to index 0."
|
|
76
|
+
)
|
|
77
|
+
if not all(
|
|
78
|
+
isinstance(token, str)
|
|
79
|
+
and isinstance(index, int)
|
|
80
|
+
and not isinstance(index, bool)
|
|
81
|
+
and index >= 0
|
|
82
|
+
for token, index in t2i.items()
|
|
83
|
+
):
|
|
84
|
+
raise CheckpointError(
|
|
85
|
+
f"Vocab namespace {namespace!r} must map strings to non-negative integers."
|
|
86
|
+
)
|
|
87
|
+
indices = list(t2i.values())
|
|
88
|
+
if len(indices) != len(set(indices)) or sorted(indices) != list(range(len(indices))):
|
|
89
|
+
raise CheckpointError(
|
|
90
|
+
f"Vocab namespace {namespace!r} indices must be unique and contiguous from 0."
|
|
91
|
+
)
|
|
92
|
+
vocab.add_namespace(namespace)
|
|
93
|
+
for token, _idx in sorted(t2i.items(), key=lambda kv: kv[1]):
|
|
94
|
+
if token != NULL_LABEL:
|
|
95
|
+
vocab.add_token(token, namespace)
|
|
96
|
+
return vocab
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def derive_dataset(vocab: Vocabulary) -> str:
|
|
100
|
+
"""Derive the single ``dataset`` prefix from the vocab's namespaces.
|
|
101
|
+
|
|
102
|
+
The model was trained with one ``dataset`` constant (``"radgraph-it"`` for the current
|
|
103
|
+
artifact), which prefixes both label namespaces. We recover it from the vocab rather than
|
|
104
|
+
hard-coding it, and validate that the NER and relation prefixes agree, so the head lookups
|
|
105
|
+
``f"{dataset}__ner_labels"`` / ``f"{dataset}__relation_labels"`` are guaranteed to hit.
|
|
106
|
+
"""
|
|
107
|
+
ner_prefixes = [
|
|
108
|
+
ns[: -len(NER_SUFFIX)] for ns in vocab.get_namespaces() if ns.endswith(NER_SUFFIX)
|
|
109
|
+
]
|
|
110
|
+
rel_prefixes = [
|
|
111
|
+
ns[: -len(RELATION_SUFFIX)] for ns in vocab.get_namespaces() if ns.endswith(RELATION_SUFFIX)
|
|
112
|
+
]
|
|
113
|
+
if len(ner_prefixes) != 1 or len(rel_prefixes) != 1:
|
|
114
|
+
raise CheckpointError(
|
|
115
|
+
"vocab.json must contain exactly one '*__ner_labels' and one '*__relation_labels' "
|
|
116
|
+
f"namespace; found NER={ner_prefixes}, relation={rel_prefixes}."
|
|
117
|
+
)
|
|
118
|
+
if ner_prefixes[0] != rel_prefixes[0]:
|
|
119
|
+
raise CheckpointError(
|
|
120
|
+
f"vocab.json NER prefix {ner_prefixes[0]!r} does not match relation prefix "
|
|
121
|
+
f"{rel_prefixes[0]!r}; the bundle is inconsistent."
|
|
122
|
+
)
|
|
123
|
+
return ner_prefixes[0]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Decode raw DyGIE backend output into immutable domain value objects.
|
|
2
|
+
|
|
3
|
+
The backend speaks in plain tuples (the shape upstream DyGIE ``decode`` produced); this module is
|
|
4
|
+
the single place that lifts those into typed :class:`ScoredEntity` / :class:`ScoredRelation`
|
|
5
|
+
objects, producing one :class:`ReportPrediction` per report. Keeping this separate from both the
|
|
6
|
+
torch backend and the serializer means the whole "raw predictions -> domain graph" step is pure
|
|
7
|
+
and trivially testable with synthetic tuples.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..core.models import (
|
|
13
|
+
RawBackendOutput,
|
|
14
|
+
ReportPrediction,
|
|
15
|
+
ScoredEntity,
|
|
16
|
+
ScoredRelation,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_report(
|
|
21
|
+
doc_key: str,
|
|
22
|
+
tokens: tuple[str, ...],
|
|
23
|
+
raw: RawBackendOutput,
|
|
24
|
+
) -> ReportPrediction:
|
|
25
|
+
"""Lift one report's raw backend tuples into a typed :class:`ReportPrediction`."""
|
|
26
|
+
entities = tuple(
|
|
27
|
+
ScoredEntity(
|
|
28
|
+
start_ix=int(start),
|
|
29
|
+
end_ix=int(end),
|
|
30
|
+
label=str(label),
|
|
31
|
+
raw_score=float(raw_score),
|
|
32
|
+
softmax_score=float(soft_score),
|
|
33
|
+
)
|
|
34
|
+
for (start, end, label, raw_score, soft_score) in raw.ner
|
|
35
|
+
)
|
|
36
|
+
relations = tuple(
|
|
37
|
+
ScoredRelation(
|
|
38
|
+
label=str(label),
|
|
39
|
+
source_start=int(s1),
|
|
40
|
+
source_end=int(e1),
|
|
41
|
+
target_start=int(s2),
|
|
42
|
+
target_end=int(e2),
|
|
43
|
+
raw_score=float(raw_score),
|
|
44
|
+
softmax_score=float(soft_score),
|
|
45
|
+
)
|
|
46
|
+
for (s1, e1, s2, e2, label, raw_score, soft_score) in raw.relations
|
|
47
|
+
)
|
|
48
|
+
return ReportPrediction(
|
|
49
|
+
doc_key=doc_key,
|
|
50
|
+
tokens=tokens,
|
|
51
|
+
entities=entities,
|
|
52
|
+
relations=relations,
|
|
53
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Inference orchestration, with no global I/O.
|
|
2
|
+
|
|
3
|
+
Ties the pure pieces together — validate input, tokenize, run the (injected) backend, decode to
|
|
4
|
+
domain objects, serialize to RadGraph-XL — around any :class:`InferenceBackend`. The backend is a
|
|
5
|
+
constructor argument, which is exactly where a fake backend is injected in tests; the service
|
|
6
|
+
itself never imports torch.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from ..core.errors import InvalidReportError
|
|
14
|
+
from ..core.models import InferenceBackend, ReportPrediction
|
|
15
|
+
from ..serialization.radgraph_xl import (
|
|
16
|
+
SerializationDiagnostics,
|
|
17
|
+
to_radgraph_xl,
|
|
18
|
+
to_radgraph_xl_with_diagnostics,
|
|
19
|
+
)
|
|
20
|
+
from .decoder import decode_report
|
|
21
|
+
from .tokenizer import tokenize_report
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_reports(reports: str | Sequence[str]) -> list[str]:
|
|
25
|
+
"""Validate and normalize the public input into a non-empty list of non-empty strings.
|
|
26
|
+
|
|
27
|
+
Accepts a single ``str`` or a ``Sequence[str]``. Rejects — deterministically, with
|
|
28
|
+
:class:`InvalidReportError` — anything else: the wrong type, an empty container, a non-string
|
|
29
|
+
element, or a string that is empty or whitespace-only. Order is preserved.
|
|
30
|
+
|
|
31
|
+
Unlike the upstream package, an empty report is never silently coerced to the string
|
|
32
|
+
``"None"``; it is an error the caller must see.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(reports, str):
|
|
35
|
+
items = [reports]
|
|
36
|
+
elif isinstance(reports, Sequence) and not isinstance(reports, bytes | bytearray):
|
|
37
|
+
items = list(reports)
|
|
38
|
+
else:
|
|
39
|
+
raise InvalidReportError(
|
|
40
|
+
f"reports must be a str or a sequence of str, got {type(reports).__name__}."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not items:
|
|
44
|
+
raise InvalidReportError("reports is empty: provide at least one report.")
|
|
45
|
+
|
|
46
|
+
for index, item in enumerate(items):
|
|
47
|
+
if not isinstance(item, str):
|
|
48
|
+
raise InvalidReportError(
|
|
49
|
+
f"report at index {index} must be a str, got {type(item).__name__}."
|
|
50
|
+
)
|
|
51
|
+
if not item.strip():
|
|
52
|
+
raise InvalidReportError(f"report at index {index} is empty or whitespace-only.")
|
|
53
|
+
return items
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InferenceService:
|
|
57
|
+
"""Stateless orchestration around a single backend. One report is processed at a time."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, backend: InferenceBackend):
|
|
60
|
+
self._backend = backend
|
|
61
|
+
|
|
62
|
+
def predict_graphs(self, reports: str | Sequence[str]) -> list[ReportPrediction]:
|
|
63
|
+
"""Validate, tokenize, infer, and decode — returning the typed domain graphs (in order)."""
|
|
64
|
+
items = normalize_reports(reports)
|
|
65
|
+
predictions: list[ReportPrediction] = []
|
|
66
|
+
for index, text in enumerate(items):
|
|
67
|
+
tokenized = tokenize_report(text)
|
|
68
|
+
raw = self._backend.infer(tokenized.tokens)
|
|
69
|
+
predictions.append(decode_report(str(index), tokenized.tokens, raw))
|
|
70
|
+
return predictions
|
|
71
|
+
|
|
72
|
+
def predict(self, reports: str | Sequence[str]) -> dict[str, object]:
|
|
73
|
+
"""Return the canonical RadGraph-XL dict (the public contract)."""
|
|
74
|
+
return to_radgraph_xl(self.predict_graphs(reports))
|
|
75
|
+
|
|
76
|
+
def predict_with_diagnostics(
|
|
77
|
+
self, reports: str | Sequence[str]
|
|
78
|
+
) -> tuple[dict[str, object], SerializationDiagnostics]:
|
|
79
|
+
"""Return the RadGraph-XL dict plus internal serialization diagnostics."""
|
|
80
|
+
return to_radgraph_xl_with_diagnostics(self.predict_graphs(reports))
|