roccoangelella commited on
Commit
0c48771
·
verified ·
1 Parent(s): 9f5eed0

Add transformers-compatible wrapper (AutoModel/AutoTokenizer via trust_remote_code)

Browse files
README.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - it
5
+ tags:
6
+ - radiology
7
+ - information-extraction
8
+ - named-entity-recognition
9
+ - relation-extraction
10
+ - medical
11
+ - radgraph
12
+ ---
13
+
14
+ # Radgraph-IT-v1
15
+
16
+ A DyGIE++-style joint model for named entity recognition and relation extraction over Italian
17
+ radiology reports, following the RadGraph schema. Encoder: [`IVN-RIN/medBIT-r3-plus`](https://huggingface.co/IVN-RIN/medBIT-r3-plus).
18
+
19
+ Entity labels: `Anatomy` / `Observation`, each with `definitely present` / `definitely absent` /
20
+ `uncertain`. Relation labels: `modify`, `located_at`, `suggestive_of`.
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from transformers import AutoModel
26
+
27
+ model = AutoModel.from_pretrained("radgraphIT/Radgraph-IT-v1", trust_remote_code=True)
28
+ model.eval()
29
+
30
+ out = model.predict("Non versamento pleurico. Addensamento parenchimale al lobo inferiore del polmone destro.")
31
+ print(out["words"])
32
+ print(out["entities"]) # [start, end, label, raw_score, softmax_score] (word-index spans, inclusive)
33
+ print(out["relations"]) # [start1, end1, start2, end2, label, raw_score, softmax_score]
34
+ ```
35
+
36
+ `model.predict(text)` applies the same word-level preprocessing (regex cleanup +
37
+ `nltk.wordpunct_tokenize`) used at training time. If you already have a word-tokenized list
38
+ matching that exact scheme, use `model.predict_words(words)` to skip re-tokenizing.
39
+
40
+ The bundled tokenizer (`AutoTokenizer.from_pretrained("radgraphIT/Radgraph-IT-v1")`) is the
41
+ underlying subword tokenizer for the transformer encoder; it is not the word-level splitter
42
+ `predict()` uses internally.
43
+
44
+ Requires `transformers`, `torch`, `numpy`, and `nltk` (for `predict()`).
45
+
46
+ ## Architecture
47
+
48
+ Word-level embeddings come from mean-pooling `medBIT-r3-plus` subwords back to one vector per
49
+ word (documents longer than 512 tokens are split into word-aligned chunks). Span representations
50
+ concatenate start/end word embeddings with a span-width embedding. NER scores every candidate
51
+ span (width ≤ 12); the relation head prunes to the top spans-per-word and scores every pair.
52
+
53
+ Trained on the full (non-cross-validation) split -- see repo commit history for provenance
54
+ (`fold0/` → `model/`, port of a private AllenNLP-based training pipeline to a from-scratch,
55
+ framework-free PyTorch + 🤗 `transformers` implementation).
56
+
57
+ ## Metrics (validation, best epoch 8)
58
+
59
+ | | NER | Relation |
60
+ |---|---|---|
61
+ | Precision | 0.848 | 0.700 |
62
+ | Recall | 0.868 | 0.680 |
63
+ | F1 | 0.858 | 0.690 |
64
+
65
+ Full per-class breakdown in `model/metrics.json`.
__init__.py ADDED
File without changes
config.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "radgraph_it",
3
+ "architectures": [
4
+ "RadgraphModel"
5
+ ],
6
+ "auto_map": {
7
+ "AutoConfig": "configuration_radgraph.RadgraphConfig",
8
+ "AutoModel": "modeling_radgraph.RadgraphModel"
9
+ },
10
+ "encoder_name": "IVN-RIN/medBIT-r3-plus",
11
+ "max_length": 512,
12
+ "max_span_width": 12,
13
+ "feature_size": 20,
14
+ "feedforward_params": {
15
+ "hidden_dims": [
16
+ 150,
17
+ 150
18
+ ],
19
+ "dropout": 0.4
20
+ },
21
+ "loss_weights": {
22
+ "ner": 0.2,
23
+ "relation": 1.0
24
+ },
25
+ "relation_spans_per_word": 0.5,
26
+ "train_encoder": true,
27
+ "span_pooling": false,
28
+ "transformer_params": null,
29
+ "relation_context": false,
30
+ "relation_feedforward_params": null,
31
+ "dataset": "radgraph-it",
32
+ "ner_labels": {
33
+ "": 0,
34
+ "Anatomy::definitely present": 1,
35
+ "Observation::definitely present": 2,
36
+ "Observation::definitely absent": 3,
37
+ "Observation::uncertain": 4,
38
+ "Anatomy::definitely absent": 5,
39
+ "Anatomy::uncertain": 6
40
+ },
41
+ "relation_labels": {
42
+ "": 0,
43
+ "modify": 1,
44
+ "located_at": 2,
45
+ "suggestive_of": 3
46
+ },
47
+ "torch_dtype": "float32",
48
+ "transformers_version": "5.14.1"
49
+ }
configuration_radgraph.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """transformers-compatible config for the RadGraph-IT DyGIE++ joint NER + relation model.
2
+
3
+ Mirrors the hyperparameters of `training_v2/src/dygie/model.py`'s `DyGIEModel` plus the
4
+ label vocabulary (`training_v2/src/dygie/vocab.py`'s `Vocabulary`), so a `RadgraphModel` can
5
+ be reconstructed from `config.json` alone, matching the checkpoint trained by
6
+ `run_medbit_full_cv.sh` (single train run on the full split, not actual cross-validation --
7
+ see that script's own header comment).
8
+ """
9
+ from transformers import PretrainedConfig
10
+
11
+
12
+ class RadgraphConfig(PretrainedConfig):
13
+ model_type = "radgraph_it"
14
+
15
+ def __init__(
16
+ self,
17
+ encoder_name: str = "IVN-RIN/medBIT-r3-plus",
18
+ max_length: int = 512,
19
+ max_span_width: int = 12,
20
+ feature_size: int = 20,
21
+ feedforward_params: dict = None,
22
+ loss_weights: dict = None,
23
+ relation_spans_per_word: float = 0.5,
24
+ train_encoder: bool = True,
25
+ span_pooling: bool = False,
26
+ transformer_params: dict = None,
27
+ relation_context: bool = False,
28
+ relation_feedforward_params: dict = None,
29
+ dataset: str = "radgraph-it",
30
+ ner_labels: dict = None,
31
+ relation_labels: dict = None,
32
+ **kwargs,
33
+ ):
34
+ self.encoder_name = encoder_name
35
+ self.max_length = max_length
36
+ self.max_span_width = max_span_width
37
+ self.feature_size = feature_size
38
+ self.feedforward_params = feedforward_params or {"hidden_dims": [150, 150], "dropout": 0.4}
39
+ self.loss_weights = loss_weights or {"ner": 0.2, "relation": 1.0}
40
+ self.relation_spans_per_word = relation_spans_per_word
41
+ self.train_encoder = train_encoder
42
+ self.span_pooling = span_pooling
43
+ self.transformer_params = transformer_params
44
+ self.relation_context = relation_context
45
+ self.relation_feedforward_params = relation_feedforward_params
46
+ self.dataset = dataset
47
+ # Namespace-unqualified label -> index maps, null label "" pinned to 0. Namespaced as
48
+ # f"{dataset}__ner_labels" / f"{dataset}__relation_labels" when rebuilt into a Vocabulary
49
+ # (see modeling_radgraph.py), matching training_v2/src/dygie/vocab.py exactly.
50
+ self.ner_labels = ner_labels or {
51
+ "": 0,
52
+ "Anatomy::definitely present": 1,
53
+ "Observation::definitely present": 2,
54
+ "Observation::definitely absent": 3,
55
+ "Observation::uncertain": 4,
56
+ "Anatomy::definitely absent": 5,
57
+ "Anatomy::uncertain": 6,
58
+ }
59
+ self.relation_labels = relation_labels or {
60
+ "": 0,
61
+ "modify": 1,
62
+ "located_at": 2,
63
+ "suggestive_of": 3,
64
+ }
65
+ super().__init__(**kwargs)
dataset.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JSONL -> training examples.
2
+
3
+ Port of the relevant half of `radgraph.dygie.data.dataset_readers.dygie.DyGIEReader`
4
+ (enumerate candidate spans, attach NER/relation gold labels, drop gold spans wider than
5
+ `max_span_width`) plus the coref/event fields it also reads but which this project never
6
+ weights (see training/README.md: loss_weights.coref = loss_weights.events = 0 in every config).
7
+
8
+ Simplification vs. v1: every document in this dataset is a single "sentence" spanning the
9
+ whole report (see training/README.md), so there is no batching-over-sentences dimension here
10
+ -- one `Example` = one document. `build_example` asserts this so a future multi-sentence
11
+ document fails loudly instead of silently mis-training.
12
+
13
+ Simplification vs. v1 (memory): AllenNLP's `AdjacencyField` materializes a dense
14
+ (num_spans, num_spans) gold-relation matrix per document at collate time -- for a long report
15
+ with ~2000 words and max_span_width=12 that's ~24k spans, i.e. a ~575M-cell tensor. We instead
16
+ keep gold relations as a sparse {(span_i, span_j): label_id} dict and only ever materialize the
17
+ small (num_kept, num_kept) submatrix after pruning (see model.py), which is mathematically
18
+ identical (same loss, same gradients) at a fraction of the memory.
19
+ """
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Dict, List, Tuple
23
+
24
+ import torch
25
+ from torch.utils.data import Dataset as TorchDataset
26
+
27
+ from .document import Document, Sentence
28
+ from .vocab import Vocabulary
29
+
30
+
31
+ def enumerate_spans(words: List[str], max_span_width: int) -> List[Tuple[int, int]]:
32
+ """All inclusive (start, end) spans of width 1..max_span_width, ordered by (start, end)."""
33
+ n = len(words)
34
+ spans = []
35
+ for start in range(n):
36
+ for end in range(start, min(start + max_span_width, n)):
37
+ spans.append((start, end))
38
+ return spans
39
+
40
+
41
+ def _too_long(span: Tuple[int, int], max_span_width: int) -> bool:
42
+ return span[1] - span[0] + 1 > max_span_width
43
+
44
+
45
+ @dataclass
46
+ class Example:
47
+ doc_key: object
48
+ dataset: str
49
+ words: List[str]
50
+ spans: List[Tuple[int, int]]
51
+ span_index: Dict[Tuple[int, int], int]
52
+ ner_label_ids: torch.Tensor # (num_spans,) long, 0 = null
53
+ relation_gold: Dict[Tuple[int, int], int] # (span_i, span_j) -> label id (>=1), sparse
54
+ weight: float
55
+ sentence: Sentence # unfiltered gold, for relation eval (see module docstring)
56
+
57
+
58
+ def build_example(doc: Document, vocab: Vocabulary, max_span_width: int) -> Example:
59
+ if len(doc.sentences) != 1:
60
+ raise NotImplementedError(
61
+ f"training_v2 assumes one sentence per document; {doc.doc_key} has {len(doc.sentences)}. "
62
+ "See training/README.md ('the whole report is a single sentence')."
63
+ )
64
+ sent = doc.sentences[0]
65
+ ner_ns = f"{doc.dataset}__ner_labels"
66
+ rel_ns = f"{doc.dataset}__relation_labels"
67
+
68
+ spans = enumerate_spans(sent.text, max_span_width)
69
+ span_index = {s: i for i, s in enumerate(spans)}
70
+
71
+ ner_label_ids = torch.zeros(len(spans), dtype=torch.long)
72
+ for span, label in (sent.ner_dict or {}).items():
73
+ if _too_long(span, max_span_width):
74
+ continue
75
+ ner_label_ids[span_index[span]] = vocab.get_token_index(label, ner_ns)
76
+
77
+ relation_gold: Dict[Tuple[int, int], int] = {}
78
+ for (span1, span2), label in (sent.relation_dict or {}).items():
79
+ if _too_long(span1, max_span_width) or _too_long(span2, max_span_width):
80
+ continue
81
+ relation_gold[(span_index[span1], span_index[span2])] = vocab.get_token_index(label, rel_ns)
82
+
83
+ return Example(doc_key=doc.doc_key, dataset=doc.dataset, words=sent.text, spans=spans,
84
+ span_index=span_index, ner_label_ids=ner_label_ids, relation_gold=relation_gold,
85
+ weight=doc.weight if doc.weight is not None else 1.0, sentence=sent)
86
+
87
+
88
+ def read_documents(path: str) -> List[Document]:
89
+ import json
90
+ documents = []
91
+ with open(path) as f:
92
+ for line in f:
93
+ line = line.strip()
94
+ if line:
95
+ documents.append(Document.from_json(json.loads(line)))
96
+ return documents
97
+
98
+
99
+ class ExampleDataset(TorchDataset):
100
+ """One dataset split (train/dev/test), pre-built into `Example`s.
101
+
102
+ `collate_fn` is the identity on the single element: every config in this project uses
103
+ `data_loader.batch_size = 1` (the joint model does not support batching multiple documents,
104
+ see model.py), so there is no padding/collation logic to write.
105
+ """
106
+
107
+ def __init__(self, documents: List[Document], vocab: Vocabulary, max_span_width: int):
108
+ self.examples = [build_example(doc, vocab, max_span_width) for doc in documents]
109
+
110
+ def __len__(self):
111
+ return len(self.examples)
112
+
113
+ def __getitem__(self, ix) -> Example:
114
+ return self.examples[ix]
115
+
116
+ @classmethod
117
+ def from_jsonl(cls, path: str, vocab: Vocabulary, max_span_width: int) -> "ExampleDataset":
118
+ return cls(read_documents(path), vocab, max_span_width)
119
+
120
+
121
+ def collate_fn(batch: List[Example]) -> Example:
122
+ assert len(batch) == 1, "training_v2 only supports batch_size=1 (see module docstring)."
123
+ return batch[0]
document.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory representation of a DyGIE-format document (JSONL).
2
+
3
+ Trimmed port of the vendored radgraph.dygie.data.dataset_readers.document module: only the
4
+ NER + relation pieces are kept (no coreference clusters, no events -- this project's configs
5
+ always set loss_weights.coref = loss_weights.events = 0, so those heads never ran in v1
6
+ either). See training/README.md for the schema.
7
+ """
8
+ import re
9
+ import json
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import numpy as np
13
+
14
+
15
+ def _fields_to_batches(d: dict, keys_to_ignore=()):
16
+ """Inverse of `_batches_to_fields`: {"a": [1, 2], "b": [3, 4]} -> [{"a": 1, "b": 3}, {"a": 2, "b": 4}]."""
17
+ keys = [k for k in d.keys() if k not in keys_to_ignore]
18
+ lengths = {k: len(d[k]) for k in keys}
19
+ if len(set(lengths.values())) != 1:
20
+ raise ValueError(f"For document {d.get('doc_key')}, fields have different lengths: {lengths}.")
21
+ length = next(iter(lengths.values()))
22
+ return [{k: d[k][i] for k in keys} for i in range(length)]
23
+
24
+
25
+ def _batches_to_fields(batches: List[dict]):
26
+ first_keys = batches[0].keys()
27
+ for entry in batches[1:]:
28
+ if set(entry.keys()) != set(first_keys):
29
+ raise ValueError("Keys do not match on all entries.")
30
+ res = {k: [] for k in first_keys}
31
+ for batch in batches:
32
+ for k, v in batch.items():
33
+ res[k].append(v)
34
+ return res
35
+
36
+
37
+ class Span:
38
+ """A span, tracked both sentence-relative and document-relative."""
39
+
40
+ def __init__(self, start: int, end: int, sentence: "Sentence", sentence_offsets: bool = False):
41
+ self.sentence = sentence
42
+ # `sentence.text_joined` is memoized on Sentence (computed once) rather than rejoined
43
+ # here per span: the relation head can construct O(K^2) PredictedRelation/Span objects
44
+ # per document during decode (K = pruned span count), so re-joining a ~1000-word
45
+ # sentence per span turns into the dominant runtime cost otherwise.
46
+ self.sentence_text = sentence.text_joined
47
+ self.start_sent = start if sentence_offsets else start - sentence.sentence_start
48
+ self.end_sent = end if sentence_offsets else end - sentence.sentence_start
49
+
50
+ @property
51
+ def start_doc(self):
52
+ return self.start_sent + self.sentence.sentence_start
53
+
54
+ @property
55
+ def end_doc(self):
56
+ return self.end_sent + self.sentence.sentence_start
57
+
58
+ @property
59
+ def span_doc(self):
60
+ return (self.start_doc, self.end_doc)
61
+
62
+ @property
63
+ def span_sent(self):
64
+ return (self.start_sent, self.end_sent)
65
+
66
+ def __repr__(self):
67
+ return str(self.span_sent)
68
+
69
+ def __eq__(self, other):
70
+ return (self.span_doc == other.span_doc and self.span_sent == other.span_sent
71
+ and self.sentence == other.sentence)
72
+
73
+ def __hash__(self):
74
+ return hash(self.span_sent + (self.sentence_text,))
75
+
76
+
77
+ class NER:
78
+ def __init__(self, ner, sentence: "Sentence", sentence_offsets: bool = False):
79
+ self.span = Span(ner[0], ner[1], sentence, sentence_offsets)
80
+ self.label = ner[2]
81
+
82
+ def __repr__(self):
83
+ return f"{self.span!r}: {self.label}"
84
+
85
+ def __eq__(self, other):
86
+ return self.span == other.span and self.label == other.label
87
+
88
+ def to_json(self):
89
+ return list(self.span.span_doc) + [self.label]
90
+
91
+
92
+ def _format_float(x):
93
+ return round(x, 4)
94
+
95
+
96
+ class PredictedNER(NER):
97
+ def __init__(self, ner, sentence, sentence_offsets: bool = False):
98
+ """`ner` = [span_start, span_end, label, raw_score, softmax_score]."""
99
+ super().__init__(ner, sentence, sentence_offsets)
100
+ self.raw_score = ner[3]
101
+ self.softmax_score = ner[4]
102
+
103
+ def to_json(self):
104
+ return super().to_json() + [_format_float(self.raw_score), _format_float(self.softmax_score)]
105
+
106
+
107
+ class Relation:
108
+ def __init__(self, relation, sentence: "Sentence", sentence_offsets: bool = False):
109
+ start1, end1, start2, end2, label = relation
110
+ self.pair = (Span(start1, end1, sentence, sentence_offsets),
111
+ Span(start2, end2, sentence, sentence_offsets))
112
+ self.label = label
113
+
114
+ def __repr__(self):
115
+ return f"{self.pair[0]!r}, {self.pair[1]!r}: {self.label}"
116
+
117
+ def __eq__(self, other):
118
+ return self.pair == other.pair and self.label == other.label
119
+
120
+ def to_json(self):
121
+ return list(self.pair[0].span_doc) + list(self.pair[1].span_doc) + [self.label]
122
+
123
+
124
+ class PredictedRelation(Relation):
125
+ def __init__(self, relation, sentence, sentence_offsets: bool = False):
126
+ """`relation` = [start1, end1, start2, end2, label, raw_score, softmax_score]."""
127
+ super().__init__(relation[:5], sentence, sentence_offsets)
128
+ self.raw_score = relation[5]
129
+ self.softmax_score = relation[6]
130
+
131
+ def to_json(self):
132
+ return super().to_json() + [_format_float(self.raw_score), _format_float(self.softmax_score)]
133
+
134
+
135
+ class Sentence:
136
+ """Despite the name, this project's documents always have exactly one "sentence" spanning
137
+ the whole report (see training/README.md); the multi-sentence machinery is kept because
138
+ the JSONL format is naturally list-of-sentences and nothing is gained by special-casing it.
139
+ """
140
+
141
+ def __init__(self, entry: dict, sentence_start: int, sentence_ix: int):
142
+ self.sentence_start = sentence_start
143
+ self.sentence_ix = sentence_ix
144
+ self.text = entry["sentences"]
145
+ self.text_joined = " ".join(self.text) # memoized once; see Span.__init__
146
+ self.metadata = {k: v for k, v in entry.items() if k.startswith("_")}
147
+
148
+ if "ner" in entry:
149
+ self.ner = [NER(x, self) for x in entry["ner"]]
150
+ self.ner_dict = {e.span.span_sent: e.label for e in self.ner}
151
+ else:
152
+ self.ner, self.ner_dict = None, None
153
+
154
+ self.predicted_ner = ([PredictedNER(x, self) for x in entry["predicted_ner"]]
155
+ if "predicted_ner" in entry else None)
156
+
157
+ if "relations" in entry:
158
+ self.relations = [Relation(x, self) for x in entry["relations"]]
159
+ self.relation_dict = {(r.pair[0].span_sent, r.pair[1].span_sent): r.label
160
+ for r in self.relations}
161
+ else:
162
+ self.relations, self.relation_dict = None, None
163
+
164
+ self.predicted_relations = ([PredictedRelation(x, self) for x in entry["predicted_relations"]]
165
+ if "predicted_relations" in entry else None)
166
+
167
+ def to_json(self):
168
+ res = {"sentences": self.text}
169
+ if self.ner is not None:
170
+ res["ner"] = [e.to_json() for e in self.ner]
171
+ if self.predicted_ner is not None:
172
+ res["predicted_ner"] = [e.to_json() for e in self.predicted_ner]
173
+ if self.relations is not None:
174
+ res["relations"] = [r.to_json() for r in self.relations]
175
+ if self.predicted_relations is not None:
176
+ res["predicted_relations"] = [r.to_json() for r in self.predicted_relations]
177
+ res.update(self.metadata)
178
+ return res
179
+
180
+ def __len__(self):
181
+ return len(self.text)
182
+
183
+ def __repr__(self):
184
+ return " ".join(self.text)
185
+
186
+
187
+ class Document:
188
+ _ALLOWED_FIELDS = re.compile(r"doc_key|dataset|sentences|weight|.*ner$|.*relations$|^_.*")
189
+
190
+ def __init__(self, doc_key, dataset, sentences: List[Sentence], weight: Optional[float] = None):
191
+ self.doc_key = doc_key
192
+ self.dataset = dataset
193
+ self.sentences = sentences
194
+ self.weight = weight
195
+
196
+ @classmethod
197
+ def from_json(cls, js: Dict[str, Any]) -> "Document":
198
+ unexpected = [f for f in js if not cls._ALLOWED_FIELDS.match(f)]
199
+ if unexpected:
200
+ raise ValueError(f"Unexpected fields (prefix with '_' if intentional): {unexpected}")
201
+
202
+ doc_key = js["doc_key"]
203
+ dataset = js.get("dataset")
204
+ entries = _fields_to_batches(js, ("doc_key", "dataset", "weight"))
205
+ sentence_lengths = [len(e["sentences"]) for e in entries]
206
+ sentence_starts = np.roll(np.cumsum(sentence_lengths), 1)
207
+ sentence_starts[0] = 0
208
+ sentences = [Sentence(entry, int(start), ix)
209
+ for ix, (entry, start) in enumerate(zip(entries, sentence_starts.tolist()))]
210
+ return cls(doc_key, dataset, sentences, js.get("weight"))
211
+
212
+ def to_json(self):
213
+ res = {"doc_key": self.doc_key, "dataset": self.dataset}
214
+ res.update(_batches_to_fields([s.to_json() for s in self.sentences]))
215
+ if self.weight is not None:
216
+ res["weight"] = self.weight
217
+ return res
218
+
219
+ @property
220
+ def n_tokens(self):
221
+ return sum(len(s) for s in self.sentences)
222
+
223
+ def __getitem__(self, ix):
224
+ return self.sentences[ix]
225
+
226
+ def __len__(self):
227
+ return len(self.sentences)
228
+
229
+ def __repr__(self):
230
+ return "\n".join(f"{i}: {' '.join(s.text)}" for i, s in enumerate(self.sentences))
231
+
232
+
233
+ class Dataset:
234
+ def __init__(self, documents: List[Document]):
235
+ self.documents = documents
236
+
237
+ def __getitem__(self, i):
238
+ return self.documents[i]
239
+
240
+ def __len__(self):
241
+ return len(self.documents)
242
+
243
+ @classmethod
244
+ def from_jsonl(cls, fname):
245
+ documents = []
246
+ with open(fname) as f:
247
+ for line in f:
248
+ documents.append(Document.from_json(json.loads(line)))
249
+ return cls(documents)
250
+
251
+ def to_jsonl(self, fname):
252
+ with open(fname, "w") as f:
253
+ for doc in self.documents:
254
+ print(json.dumps(doc.to_json()), file=f)
feedforward.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small MLP used by every scorer (NER, mention pruner, relation): Linear -> ReLU -> Dropout,
2
+ one block per entry in `hidden_dims`, each block's width taken from that entry (see
3
+ configs/medbit_span_pooling/fold0.json: feedforward_params = {hidden_dims: [300, 150],
4
+ dropout: 0.4}).
5
+ """
6
+ from typing import List
7
+
8
+ from torch import nn
9
+
10
+
11
+ class FeedForward(nn.Module):
12
+ def __init__(self, input_dim: int, hidden_dims: List[int], dropout: float):
13
+ super().__init__()
14
+ layers = []
15
+ prev = input_dim
16
+ for dim in hidden_dims:
17
+ layers += [nn.Linear(prev, dim), nn.ReLU(), nn.Dropout(dropout)]
18
+ prev = dim
19
+ self.net = nn.Sequential(*layers)
20
+ self.output_dim = prev
21
+
22
+ def forward(self, x):
23
+ return self.net(x)
metrics.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Micro + per-class precision/recall/F1 accumulators. Port of ner_metrics.py / relation_metrics.py / f1.py.
2
+
3
+ Per-class tp/fp/fn are accumulated alongside the micro totals so macro-F1 and the rare-class
4
+ breakdown can be logged live during training (not only post-hoc in eval.py) -- same information
5
+ the v1 exp2 class-weighting runs surfaced to W&B.
6
+ """
7
+ from collections import defaultdict
8
+ from typing import Dict, Tuple
9
+
10
+ import torch
11
+
12
+
13
+ def compute_f1(predicted: float, gold: float, matched: float) -> Tuple[float, float, float]:
14
+ precision = matched / predicted if predicted > 0 else 0.0
15
+ recall = matched / gold if gold > 0 else 0.0
16
+ f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
17
+ return precision, recall, f1
18
+
19
+
20
+ class NERMetrics:
21
+ """Exact-match span+label F1, from tensors (no mask: batch_size=1 means no padding)."""
22
+
23
+ def __init__(self, num_classes: int, null_label: int = 0):
24
+ self.num_classes = num_classes
25
+ self.null_label = null_label
26
+ self.reset()
27
+
28
+ def reset(self):
29
+ self.tp = self.fp = self.fn = 0
30
+ self.per_class = {c: [0, 0, 0] for c in range(self.num_classes) if c != self.null_label}
31
+
32
+ def __call__(self, predictions: torch.Tensor, gold_labels: torch.Tensor):
33
+ for c in range(self.num_classes):
34
+ if c == self.null_label:
35
+ continue
36
+ tp = int(((predictions == c) & (gold_labels == c)).sum())
37
+ fp = int(((predictions == c) & (gold_labels != c)).sum())
38
+ fn = int(((predictions != c) & (gold_labels == c)).sum())
39
+ self.tp += tp
40
+ self.fp += fp
41
+ self.fn += fn
42
+ pc = self.per_class[c]
43
+ pc[0] += tp
44
+ pc[1] += fp
45
+ pc[2] += fn
46
+
47
+ def get_metric(self, reset: bool = False) -> Tuple[float, float, float]:
48
+ result = compute_f1(self.tp + self.fp, self.tp + self.fn, self.tp)
49
+ if reset:
50
+ self.reset()
51
+ return result
52
+
53
+ def get_per_class(self) -> Dict[int, float]:
54
+ """{class index -> F1}. Call BEFORE get_metric(reset=True), which clears the counts."""
55
+ return {c: compute_f1(v[0] + v[1], v[0] + v[2], v[0])[2] for c, v in self.per_class.items()}
56
+
57
+
58
+ class RelationMetrics:
59
+ """Exact-match (span1, span2, label) F1, decoded predictions vs. the full (unfiltered) gold
60
+ relation dict -- see dataset.py's module docstring for why gold relations whose spans exceed
61
+ max_span_width still count as false negatives here."""
62
+
63
+ def __init__(self):
64
+ self.reset()
65
+
66
+ def reset(self):
67
+ self.total_gold = self.total_predicted = self.total_matched = 0
68
+ self.per_class = defaultdict(lambda: [0, 0, 0]) # label -> [tp, fp, fn]
69
+
70
+ def __call__(self, predicted: Dict[Tuple, str], gold: Dict[Tuple, str]):
71
+ self.total_gold += len(gold)
72
+ self.total_predicted += len(predicted)
73
+ for key, label in predicted.items():
74
+ if gold.get(key) == label:
75
+ self.total_matched += 1
76
+ self.per_class[label][0] += 1 # tp
77
+ else:
78
+ self.per_class[label][1] += 1 # fp: label wrong, or key absent from gold
79
+ for key, label in gold.items():
80
+ if predicted.get(key) != label:
81
+ self.per_class[label][2] += 1 # fn
82
+
83
+ def get_metric(self, reset: bool = False) -> Tuple[float, float, float]:
84
+ result = compute_f1(self.total_predicted, self.total_gold, self.total_matched)
85
+ if reset:
86
+ self.reset()
87
+ return result
88
+
89
+ def get_per_class(self) -> Dict[str, float]:
90
+ """{relation label -> F1}. Call BEFORE get_metric(reset=True), which clears the counts."""
91
+ return {lab: compute_f1(v[0] + v[1], v[0] + v[2], v[0])[2] for lab, v in self.per_class.items()}
model.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The joint model: encoder -> span representations -> NER head + relation head.
2
+
3
+ Port of `radgraph.dygie.models.dygie.DyGIE`, coref/events removed (see package docstring in
4
+ ner_head.py / relation_head.py for why: every config in this project sets their loss weight to
5
+ 0, so they never trained or predicted anything in v1 either).
6
+ """
7
+ from typing import Dict
8
+
9
+ import torch
10
+ from torch import nn
11
+
12
+ from .dataset import Example
13
+ from .ner_head import NERHead
14
+ from .relation_head import RelationHead
15
+ from .span_extractor import EndpointSpanExtractor
16
+ from .tokenizer_embedder import MismatchedEmbedder
17
+ from .vocab import Vocabulary
18
+
19
+
20
+ def _xavier_init_weights(module: nn.Module) -> None:
21
+ """xavier_normal_ on every 2-D '*.weight' / '*.weight_matrix' param -- the
22
+ `module_initializer` regexes every config in configs/medbit/ applies to the NER and
23
+ relation submodules (never to the pretrained encoder, which keeps its pretrained init)."""
24
+ for name, param in module.named_parameters():
25
+ if param.dim() >= 2 and (name.endswith("weight") or name.endswith("weight_matrix")):
26
+ nn.init.xavier_normal_(param)
27
+
28
+
29
+ class DyGIEModel(nn.Module):
30
+ def __init__(self, vocab: Vocabulary, encoder_name: str, max_length: int, max_span_width: int,
31
+ feature_size: int, feedforward_params: dict, loss_weights: Dict[str, float],
32
+ relation_spans_per_word: float, train_encoder: bool = True,
33
+ span_pooling: bool = False, transformer_params: dict = None,
34
+ relation_context: bool = False, relation_feedforward_params: dict = None):
35
+ super().__init__()
36
+ self.vocab = vocab
37
+ self.loss_weights = loss_weights
38
+
39
+ self.embedder = MismatchedEmbedder(encoder_name, max_length, train_encoder)
40
+ self.span_extractor = EndpointSpanExtractor(
41
+ self.embedder.get_output_dim(), num_width_embeddings=max_span_width,
42
+ span_width_embedding_dim=feature_size, mean_pool=span_pooling)
43
+ span_emb_dim = self.span_extractor.get_output_dim()
44
+
45
+ self.ner = NERHead(vocab, span_emb_dim, feedforward_params, transformer_params)
46
+ self.relation = RelationHead(vocab, span_emb_dim, self.embedder.get_output_dim(),
47
+ feedforward_params, relation_spans_per_word,
48
+ transformer_params, relation_context,
49
+ relation_feedforward_params)
50
+
51
+ _xavier_init_weights(self.ner)
52
+ _xavier_init_weights(self.relation)
53
+ nn.init.xavier_normal_(self.span_extractor.width_embedding.weight)
54
+
55
+ def forward(self, example: Example) -> dict:
56
+ device = next(self.embedder.parameters()).device
57
+ word_embeddings = self.embedder(example.words)
58
+ spans_tensor = torch.tensor(example.spans, dtype=torch.long, device=device)
59
+ span_embeddings = self.span_extractor(word_embeddings, spans_tensor)
60
+
61
+ zero = word_embeddings.new_zeros(())
62
+ output_ner, output_relation = {"loss": zero}, {"loss": zero}
63
+
64
+ if self.loss_weights["ner"] > 0:
65
+ output_ner = self.ner(example.dataset, example.spans, span_embeddings, example.sentence,
66
+ example.ner_label_ids.to(device))
67
+ if self.loss_weights["relation"] > 0:
68
+ output_relation = self.relation(example.dataset, example.spans, span_embeddings,
69
+ len(example.words), word_embeddings,
70
+ example.relation_gold, example.sentence)
71
+
72
+ loss = (self.loss_weights["ner"] * output_ner.get("loss", zero) +
73
+ self.loss_weights["relation"] * output_relation.get("loss", zero))
74
+ loss = loss * example.weight
75
+
76
+ return {"loss": loss, "ner": output_ner, "relation": output_relation}
77
+
78
+ def get_metrics(self, reset: bool = False) -> Dict[str, float]:
79
+ res = {}
80
+ res.update(self.ner.get_metrics(reset))
81
+ res.update(self.relation.get_metrics(reset))
82
+ return res
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:31eb1746b8c7642d6421213911a05a522fffb026cd1818d49643f4fbe23dc4b2
3
+ size 444688808
modeling_radgraph.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """transformers-compatible wrapper around `DyGIEModel` (training_v2/src/dygie/model.py).
2
+
3
+ `RadgraphModel.model` is a plain `DyGIEModel`, so its state_dict keys are `model.<...>`,
4
+ matching the `model.` prefix applied when this repo's checkpoint was converted from the
5
+ original `best.pt` (`{"epoch": ..., "model_state_dict": ...}`, see observability.py's
6
+ `HFCheckpointUploader` / trainer.py in the training repo) to `model.safetensors`.
7
+
8
+ Two ways to run inference:
9
+ - `model.predict(text)` -- raw report text in, decoded entities/relations out. Applies the
10
+ exact word-level preprocessing training used (regex cleanup + wordpunct tokenization,
11
+ ported from radgraph/radgraph/utils.py's `radgraph_xl_preprocess_report`, verified against
12
+ the gold tokenization by check_tokenization_raw_it.py in the training repo).
13
+ - `model.predict_words(words)` -- skip preprocessing if you already have the exact
14
+ word-tokenized list (e.g. re-running on the original training data).
15
+ """
16
+ import re
17
+ from typing import List
18
+
19
+ import torch
20
+ from transformers import PreTrainedModel
21
+ from nltk.tokenize import wordpunct_tokenize
22
+
23
+ from .configuration_radgraph import RadgraphConfig
24
+ from .document import Document
25
+ from .model import DyGIEModel
26
+ from .predictor import predict_document
27
+ from .vocab import Vocabulary
28
+
29
+
30
+ def _preprocess_report(text: str) -> str:
31
+ """Exact port of radgraph/radgraph/utils.py:34-48 (inference-time preprocessing)."""
32
+ text = text.replace("\\n", " ")
33
+ text = text.replace("\\f", " ")
34
+ text = text.replace("\\u2122", " ")
35
+ text = text.replace("\n", " ")
36
+ text = text.replace("\\\"", "``")
37
+ text_sub = re.sub(r"\s+", " ", text)
38
+ t = " ".join(wordpunct_tokenize(text_sub))
39
+ t = t.replace(").", ") .")
40
+ t = t.replace("%.", "% .")
41
+ t = t.replace(".'", ". '")
42
+ t = t.replace("%,", "% ,")
43
+ t = t.replace("%)", "% )")
44
+ return t
45
+
46
+
47
+ def _build_vocab(config: RadgraphConfig) -> Vocabulary:
48
+ vocab = Vocabulary()
49
+ ner_ns = f"{config.dataset}__ner_labels"
50
+ rel_ns = f"{config.dataset}__relation_labels"
51
+ vocab.add_namespace(ner_ns)
52
+ vocab.add_namespace(rel_ns)
53
+ for token, _ in sorted(config.ner_labels.items(), key=lambda kv: kv[1]):
54
+ if token != "":
55
+ vocab.add_token(token, ner_ns)
56
+ for token, _ in sorted(config.relation_labels.items(), key=lambda kv: kv[1]):
57
+ if token != "":
58
+ vocab.add_token(token, rel_ns)
59
+ return vocab
60
+
61
+
62
+ class RadgraphModel(PreTrainedModel):
63
+ config_class = RadgraphConfig
64
+
65
+ def __init__(self, config: RadgraphConfig):
66
+ super().__init__(config)
67
+ self.vocab = _build_vocab(config)
68
+ self.model = DyGIEModel(
69
+ vocab=self.vocab,
70
+ encoder_name=config.encoder_name,
71
+ max_length=config.max_length,
72
+ max_span_width=config.max_span_width,
73
+ feature_size=config.feature_size,
74
+ feedforward_params=config.feedforward_params,
75
+ loss_weights=config.loss_weights,
76
+ relation_spans_per_word=config.relation_spans_per_word,
77
+ train_encoder=config.train_encoder,
78
+ span_pooling=config.span_pooling,
79
+ transformer_params=config.transformer_params,
80
+ relation_context=config.relation_context,
81
+ relation_feedforward_params=config.relation_feedforward_params,
82
+ )
83
+ # Required by transformers' from_pretrained machinery (sets up all_tied_weights_keys
84
+ # etc.) -- harmless here since DyGIEModel has no tied weights and the checkpoint's
85
+ # state dict gets loaded over whatever this touches anyway.
86
+ self.post_init()
87
+
88
+ def forward(self, words: List[str], doc_key: str = "doc"):
89
+ """Low-level forward pass: pre-tokenized words in, raw model output dict out
90
+ (see DyGIEModel.forward / predict_document)."""
91
+ doc = Document.from_json({"doc_key": doc_key, "dataset": self.config.dataset, "sentences": [words]})
92
+ return predict_document(self.model, self.vocab, doc, self.config.max_span_width)
93
+
94
+ @torch.no_grad()
95
+ def predict_words(self, words: List[str], doc_key: str = "doc") -> dict:
96
+ result = self.forward(words, doc_key=doc_key)
97
+ return {
98
+ "doc_key": result["doc_key"],
99
+ "words": words,
100
+ "entities": [e.to_json() for e in result["predicted_ner"]],
101
+ "relations": [r.to_json() for r in result["predicted_relations"]],
102
+ }
103
+
104
+ @torch.no_grad()
105
+ def predict(self, text: str, doc_key: str = "doc") -> dict:
106
+ words = _preprocess_report(text).split()
107
+ return self.predict_words(words, doc_key=doc_key)
ner_head.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NER head: score every candidate span, cross-entropy against the gold label (or null).
2
+
3
+ Port of `radgraph.dygie.models.ner.NERTagger`. Simplified for batch_size=1 (dataset.py): no
4
+ span mask, no per-sentence looping -- one document's worth of spans at a time.
5
+
6
+ `transformer_params` (medbit_transformer arm, see notes/v2_plan.md item 1) swaps the plain
7
+ FeedForward scorer for a TransformerBlock: every candidate span attends to every other
8
+ candidate span in the document before classification, instead of each span being scored in
9
+ isolation. NER scores every candidate span unpruned, so this is full O(n^2) self-attention
10
+ over the whole doc's span pool (accepted cost, not capped -- see v2_plan.md item 1).
11
+ """
12
+ from typing import Dict, List
13
+
14
+ import torch
15
+ from torch import nn
16
+
17
+ from .document import PredictedNER, Sentence
18
+ from .feedforward import FeedForward
19
+ from .metrics import NERMetrics
20
+ from .transformer_block import TransformerBlock
21
+ from .vocab import Vocabulary
22
+
23
+
24
+ class NERHead(nn.Module):
25
+ def __init__(self, vocab: Vocabulary, span_emb_dim: int, feedforward_params: dict,
26
+ transformer_params: dict = None):
27
+ super().__init__()
28
+ self.vocab = vocab
29
+ self.namespaces = [ns for ns in vocab.get_namespaces() if ns.endswith("ner_labels")]
30
+ self.n_labels = {ns: vocab.get_vocab_size(ns) for ns in self.namespaces}
31
+ self.use_transformer = transformer_params is not None
32
+
33
+ self.scorers = nn.ModuleDict()
34
+ self.classifiers = nn.ModuleDict()
35
+ self.metrics: Dict[str, NERMetrics] = {}
36
+ for ns in self.namespaces:
37
+ if self.use_transformer:
38
+ body = TransformerBlock(span_emb_dim, **transformer_params)
39
+ else:
40
+ body = FeedForward(span_emb_dim, feedforward_params["hidden_dims"],
41
+ feedforward_params["dropout"])
42
+ self.scorers[ns] = body
43
+ self.classifiers[ns] = nn.Linear(body.output_dim, self.n_labels[ns] - 1)
44
+ self.metrics[ns] = NERMetrics(self.n_labels[ns])
45
+
46
+ self._loss = nn.CrossEntropyLoss(reduction="sum")
47
+
48
+ def forward(self, dataset: str, spans: List[tuple], span_embeddings: torch.Tensor,
49
+ sentence: Sentence, ner_label_ids: torch.Tensor = None) -> dict:
50
+ ns = f"{dataset}__ner_labels"
51
+ if self.use_transformer:
52
+ starts = torch.tensor([s[0] for s in spans], dtype=torch.long,
53
+ device=span_embeddings.device)
54
+ hidden = self.scorers[ns](span_embeddings, starts)
55
+ else:
56
+ hidden = self.scorers[ns](span_embeddings)
57
+ scores = self.classifiers[ns](hidden) # (num_spans, n_labels - 1)
58
+ dummy = scores.new_zeros(scores.size(0), 1) # null-label score, fixed at 0
59
+ scores = torch.cat([dummy, scores], dim=-1) # (num_spans, n_labels)
60
+ predicted = scores.argmax(dim=-1)
61
+
62
+ predictions = self.decode(ns, scores.detach(), spans, sentence)
63
+ output = {"namespace": ns, "scores": scores, "predicted": predicted, "predictions": predictions}
64
+ if ner_label_ids is not None:
65
+ self.metrics[ns](predicted, ner_label_ids)
66
+ output["loss"] = self._loss(scores, ner_label_ids)
67
+ return output
68
+
69
+ def decode(self, namespace: str, scores: torch.Tensor, spans: List[tuple],
70
+ sentence: Sentence) -> List[PredictedNER]:
71
+ softmax_scores = torch.softmax(scores, dim=-1)
72
+ raw, predicted = scores.max(dim=-1)
73
+ soft, _ = softmax_scores.max(dim=-1)
74
+
75
+ predictions = []
76
+ for i in (predicted != 0).nonzero(as_tuple=True)[0].tolist():
77
+ label = self.vocab.get_token_from_index(int(predicted[i]), namespace)
78
+ start, end = spans[i]
79
+ entry = [start, end, label, float(raw[i]), float(soft[i])]
80
+ predictions.append(PredictedNER(entry, sentence, sentence_offsets=True))
81
+ return predictions
82
+
83
+ def get_metrics(self, reset: bool = False) -> Dict[str, float]:
84
+ res = {}
85
+ for ns, metric in self.metrics.items():
86
+ prefix = ns.replace("_labels", "")
87
+ per_class = metric.get_per_class() # {class idx -> F1}, read BEFORE the reset below
88
+ for idx, class_f1 in per_class.items():
89
+ res[f"ner_perclass/{self.vocab.get_token_from_index(idx, ns)}"] = class_f1
90
+ res["ner_macro_f1"] = sum(per_class.values()) / len(per_class) if per_class else 0.0
91
+ precision, recall, f1 = metric.get_metric(reset)
92
+ res[f"{prefix}_precision"] = precision
93
+ res[f"{prefix}_recall"] = recall
94
+ res[f"{prefix}_f1"] = f1
95
+ for name in ("precision", "recall", "f1"):
96
+ values = [v for k, v in res.items()
97
+ if k.endswith(f"_{name}") and "perclass/" not in k and "_macro_" not in k]
98
+ res[f"MEAN__ner_{name}"] = sum(values) / len(values) if values else 0.0
99
+ return res
predictor.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a trained model on a document and get back decoded predictions.
2
+
3
+ Port of the one thing `radgraph.dygie.predictors.dygie.DyGIEPredictor` did that this project
4
+ actually used: forward pass -> `PredictedNER` / `PredictedRelation` objects, in document
5
+ (here: sentence, since there's always exactly one) coordinates.
6
+ """
7
+ import torch
8
+
9
+ from .dataset import Example, build_example
10
+ from .document import Document
11
+ from .model import DyGIEModel
12
+ from .vocab import Vocabulary
13
+
14
+
15
+ @torch.no_grad()
16
+ def predict_document(model: DyGIEModel, vocab: Vocabulary, doc: Document, max_span_width: int) -> dict:
17
+ model.eval()
18
+ example: Example = build_example(doc, vocab, max_span_width)
19
+ output = model(example)
20
+ return {
21
+ "doc_key": doc.doc_key,
22
+ "example": example,
23
+ "predicted_ner": output["ner"].get("predictions", []),
24
+ "predicted_relations": output["relation"].get("predictions", []),
25
+ }
pruner.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mention pruner: score every span, keep the top-k, in original span order.
2
+
3
+ Port of `radgraph.dygie.models.entity_beam_pruner.Pruner`'s default path (no entity_beam, no
4
+ gold_beam -- this project's relation module never sets either, see relation_head.py).
5
+ Simplified for batch_size=1: no masking, no batch dimension.
6
+ """
7
+ from typing import Tuple
8
+
9
+ import torch
10
+ from torch import nn
11
+
12
+
13
+ class Pruner(nn.Module):
14
+ def __init__(self, scorer: nn.Module):
15
+ super().__init__()
16
+ self.scorer = scorer # (num_spans, dim) -> (num_spans, 1)
17
+
18
+ def forward(self, span_embeddings: torch.Tensor, num_items_to_keep: int
19
+ ) -> Tuple[torch.Tensor, torch.LongTensor, torch.Tensor]:
20
+ scores = self.scorer(span_embeddings).squeeze(-1) # (num_spans,)
21
+ k = max(1, min(num_items_to_keep, span_embeddings.size(0)))
22
+ _, top_indices = scores.topk(k)
23
+ top_indices, _ = torch.sort(top_indices)
24
+ top_scores = scores[top_indices]
25
+ top_embeddings = span_embeddings[top_indices]
26
+ return top_embeddings, top_indices, top_scores
relation_head.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Relation head: prune to top-K spans, score every pair, cross-entropy against gold.
2
+
3
+ Port of `radgraph.dygie.models.relation.RelationExtractor`. Two deliberate simplifications
4
+ made possible by batch_size=1 (see dataset.py):
5
+
6
+ 1. No span mask / no ignore_index: every one of the K pruned spans is a real span (there's
7
+ no cross-document padding to mask out), so every (i, j) pair among them is a legitimate
8
+ loss target -- either a real relation or "null".
9
+ 2. The scorer outputs `n_labels - 1` channels (real labels only) with a null column
10
+ prepended, exactly like ner_head.py already does. v1's RelationExtractor scores the
11
+ *full* `n_labels` (which already includes the null vocab slot at index 0) and *also*
12
+ prepends a dummy null column, leaving one dead, never-targeted output channel that gets
13
+ trained toward nothing and just gets suppressed as a side effect of softmax competition.
14
+ Sizing it like NER removes that dead channel and, as a bonus, means predicted/gold label
15
+ ids line up with vocabulary indices directly -- no "+1 shift so null is class 0" dance.
16
+
17
+ `transformer_params` (medbit_transformer arm, see notes/v2_plan.md item 1) swaps the pairwise
18
+ `relation_feedforwards` scorer for a TransformerBlock applied to the pruner's already-pruned
19
+ top-K spans, *before* pair formation: the K surviving spans attend to each other, then pairs
20
+ are built from the now-contextualized embeddings and scored with a bare Linear (the
21
+ transformer's own internal FFN sublayer already did the nonlinear work `relation_feedforwards`
22
+ used to do). The pruner's own mention scorer stays a plain FeedForward -- it's a preliminary
23
+ filter picking which K spans survive, not the classifier itself.
24
+
25
+ `use_between_context` (medbit_relations_pooling arm, see notes/v2_plan.md item 2) adds a 4th
26
+ block to the pair representation: the mean-pooled *raw* word embeddings strictly between the
27
+ two spans (not the pooled span embeddings -- those don't preserve individual word positions).
28
+ Computed via a prefix sum over `word_embeddings` (`_between_context`) so all K^2 pairs are done
29
+ in one vectorized pass instead of a per-pair loop. Overlapping/adjacent/nested spans have no
30
+ words between them -> zero vector for that pair. Orthogonal to `transformer_params`: the arm
31
+ forks from the plain medbit baseline, not medbit_transformer, so both flags are never on
32
+ together in practice, but the code composes correctly either way (the between-block just adds
33
+ `word_emb_dim` to whichever pair representation -- FeedForward or transformer -- is in use).
34
+
35
+ `relation_feedforward_params` (medbit_relations_pooling arm only, defaults to `feedforward_params`
36
+ for every other arm) sizes just the relation pair scorer's hidden layers, kept separate from the
37
+ pruner's `mention_ff` -- the pruner still consumes plain `span_emb_dim` (unaffected by
38
+ `use_between_context`), so only the layers that actually see the enlarged pair embedding get
39
+ widened, mirroring the "pruner untouched" principle already applied to `transformer_params`.
40
+ """
41
+ import math
42
+ from typing import Dict, List, Tuple
43
+
44
+ import torch
45
+ from torch import nn
46
+
47
+ from .document import PredictedRelation, Sentence
48
+ from .feedforward import FeedForward
49
+ from .metrics import RelationMetrics
50
+ from .pruner import Pruner
51
+ from .transformer_block import TransformerBlock
52
+ from .vocab import Vocabulary
53
+
54
+
55
+ class RelationHead(nn.Module):
56
+ def __init__(self, vocab: Vocabulary, span_emb_dim: int, word_emb_dim: int,
57
+ feedforward_params: dict, spans_per_word: float, transformer_params: dict = None,
58
+ use_between_context: bool = False, relation_feedforward_params: dict = None):
59
+ super().__init__()
60
+ self.vocab = vocab
61
+ self.spans_per_word = spans_per_word
62
+ self.namespaces = [ns for ns in vocab.get_namespaces() if ns.endswith("relation_labels")]
63
+ self.n_labels = {ns: vocab.get_vocab_size(ns) for ns in self.namespaces}
64
+ self.use_transformer = transformer_params is not None
65
+ self.use_between_context = use_between_context
66
+ between_dim = word_emb_dim if use_between_context else 0
67
+ relation_feedforward_params = relation_feedforward_params or feedforward_params
68
+
69
+ self.pruners = nn.ModuleDict()
70
+ self.span_transformers = nn.ModuleDict()
71
+ self.relation_feedforwards = nn.ModuleDict()
72
+ self.relation_scorers = nn.ModuleDict()
73
+ self.metrics: Dict[str, RelationMetrics] = {}
74
+
75
+ for ns in self.namespaces:
76
+ mention_ff = FeedForward(span_emb_dim, feedforward_params["hidden_dims"],
77
+ feedforward_params["dropout"])
78
+ mention_scorer = nn.Sequential(mention_ff, nn.Linear(mention_ff.output_dim, 1))
79
+ self.pruners[ns] = Pruner(mention_scorer)
80
+
81
+ if self.use_transformer:
82
+ block = TransformerBlock(span_emb_dim, **transformer_params)
83
+ self.span_transformers[ns] = block
84
+ self.relation_scorers[ns] = nn.Linear(3 * block.output_dim + between_dim,
85
+ self.n_labels[ns] - 1)
86
+ else:
87
+ relation_ff = FeedForward(3 * span_emb_dim + between_dim,
88
+ relation_feedforward_params["hidden_dims"],
89
+ relation_feedforward_params["dropout"])
90
+ self.relation_feedforwards[ns] = relation_ff
91
+ self.relation_scorers[ns] = nn.Linear(relation_ff.output_dim, self.n_labels[ns] - 1)
92
+ self.metrics[ns] = RelationMetrics()
93
+
94
+ self._loss = nn.CrossEntropyLoss(reduction="sum")
95
+
96
+ @staticmethod
97
+ def _pair_embeddings(top_embeddings: torch.Tensor) -> torch.Tensor:
98
+ k = top_embeddings.size(0)
99
+ e1 = top_embeddings.unsqueeze(1).expand(k, k, -1)
100
+ e2 = top_embeddings.unsqueeze(0).expand(k, k, -1)
101
+ return torch.cat([e1, e2, e1 * e2], dim=-1)
102
+
103
+ @staticmethod
104
+ def _between_context(word_embeddings: torch.Tensor,
105
+ top_spans: List[Tuple[int, int]]) -> torch.Tensor:
106
+ """Mean of the raw word embeddings strictly between two spans, one block per (i, j) pair
107
+ -- vectorized over the whole K x K grid via a prefix sum (no per-pair loop; K^2 pairs
108
+ can run into the tens of thousands per doc). `low`/`high` don't depend on pair order (i,
109
+ j) vs (j, i), so the between-region -- and this block -- is symmetric by construction.
110
+ Overlapping/adjacent/nested spans (count <= 0) get a zero vector."""
111
+ device, dtype = word_embeddings.device, word_embeddings.dtype
112
+ starts = torch.tensor([s[0] for s in top_spans], dtype=torch.long, device=device)
113
+ ends = torch.tensor([s[1] for s in top_spans], dtype=torch.long, device=device)
114
+
115
+ zero_row = word_embeddings.new_zeros(1, word_embeddings.size(-1))
116
+ prefix = torch.cat([zero_row, word_embeddings.cumsum(dim=0)], dim=0) # (num_words + 1, dim)
117
+
118
+ low = torch.minimum(ends.unsqueeze(1), ends.unsqueeze(0)) # end of whichever span is first
119
+ high = torch.maximum(starts.unsqueeze(1), starts.unsqueeze(0)) # start of whichever span is second
120
+ count = (high - low - 1).clamp(min=0) # words strictly between
121
+
122
+ seg_sum = prefix[high] - prefix[low + 1] # (K, K, dim)
123
+ mean = seg_sum / count.clamp(min=1).unsqueeze(-1).to(dtype)
124
+ return torch.where((count > 0).unsqueeze(-1), mean, seg_sum.new_zeros(()))
125
+
126
+ def forward(self, dataset: str, spans: List[Tuple[int, int]], span_embeddings: torch.Tensor,
127
+ num_words: int, word_embeddings: torch.Tensor = None,
128
+ relation_gold: Dict[Tuple[int, int], int] = None,
129
+ sentence: Sentence = None) -> dict:
130
+ ns = f"{dataset}__relation_labels"
131
+ num_to_keep = math.ceil(num_words * self.spans_per_word)
132
+
133
+ top_embeddings, top_indices, top_scores = self.pruners[ns](span_embeddings, num_to_keep)
134
+ top_indices_list = top_indices.tolist()
135
+ top_spans = [spans[i] for i in top_indices_list]
136
+
137
+ if self.use_transformer:
138
+ starts = torch.tensor([s[0] for s in top_spans], dtype=torch.long,
139
+ device=span_embeddings.device)
140
+ top_embeddings = self.span_transformers[ns](top_embeddings, starts)
141
+
142
+ pair_embeddings = self._pair_embeddings(top_embeddings)
143
+ if self.use_between_context:
144
+ between = self._between_context(word_embeddings, top_spans)
145
+ pair_embeddings = torch.cat([pair_embeddings, between], dim=-1)
146
+
147
+ k = top_embeddings.size(0)
148
+ if self.use_transformer:
149
+ scores = self.relation_scorers[ns](pair_embeddings.view(k * k, -1)).view(k, k, -1)
150
+ else:
151
+ projected = self.relation_feedforwards[ns](pair_embeddings.view(k * k, -1))
152
+ scores = self.relation_scorers[ns](projected).view(k, k, -1)
153
+ scores = scores + top_scores.view(k, 1, 1) + top_scores.view(1, k, 1)
154
+ dummy = scores.new_zeros(k, k, 1)
155
+ scores = torch.cat([dummy, scores], dim=-1) # (K, K, n_labels), null at index 0
156
+
157
+ predicted_dict, predictions = self.decode(ns, scores.detach(), top_spans, sentence)
158
+ output = {"namespace": ns, "scores": scores, "top_spans": top_spans, "predictions": predictions}
159
+
160
+ if relation_gold is not None:
161
+ pos_of = {orig: pos for pos, orig in enumerate(top_indices_list)}
162
+ gold_matrix = scores.new_zeros(k, k, dtype=torch.long)
163
+ for (oi, oj), label_id in relation_gold.items():
164
+ pi, pj = pos_of.get(oi), pos_of.get(oj)
165
+ if pi is not None and pj is not None:
166
+ gold_matrix[pi, pj] = label_id
167
+
168
+ output["loss"] = self._loss(scores.view(k * k, -1), gold_matrix.view(-1))
169
+ self.metrics[ns](predicted_dict, sentence.relation_dict or {})
170
+
171
+ return output
172
+
173
+ def decode(self, namespace: str, scores: torch.Tensor, top_spans: List[Tuple[int, int]],
174
+ sentence: Sentence):
175
+ raw, predicted = scores.max(dim=-1)
176
+ soft, _ = torch.softmax(scores, dim=-1).max(dim=-1)
177
+
178
+ res_dict, predictions = {}, []
179
+ for i, j in (predicted != 0).nonzero(as_tuple=False).tolist():
180
+ label = self.vocab.get_token_from_index(int(predicted[i, j]), namespace)
181
+ span1, span2 = top_spans[i], top_spans[j]
182
+ res_dict[(span1, span2)] = label
183
+ entry = [span1[0], span1[1], span2[0], span2[1], label, float(raw[i, j]), float(soft[i, j])]
184
+ predictions.append(PredictedRelation(entry, sentence, sentence_offsets=True))
185
+ return res_dict, predictions
186
+
187
+ def get_metrics(self, reset: bool = False) -> Dict[str, float]:
188
+ res = {}
189
+ for ns, metric in self.metrics.items():
190
+ prefix = ns.replace("_labels", "")
191
+ per_class = metric.get_per_class() # {relation label -> F1}, read BEFORE the reset below
192
+ for label, class_f1 in per_class.items():
193
+ res[f"relation_perclass/{label}"] = class_f1
194
+ res["relation_macro_f1"] = sum(per_class.values()) / len(per_class) if per_class else 0.0
195
+ precision, recall, f1 = metric.get_metric(reset)
196
+ res[f"{prefix}_precision"] = precision
197
+ res[f"{prefix}_recall"] = recall
198
+ res[f"{prefix}_f1"] = f1
199
+ for name in ("precision", "recall", "f1"):
200
+ values = [v for k, v in res.items()
201
+ if k.endswith(f"_{name}") and "perclass/" not in k and "_macro_" not in k]
202
+ res[f"MEAN__relation_{name}"] = sum(values) / len(values) if values else 0.0
203
+ return res
span_extractor.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Span representations: concat(start word embedding, end word embedding, [mean-pooled interior
2
+ embedding], width embedding).
3
+
4
+ Port of AllenNLP's `EndpointSpanExtractor(combination="x,y", bucket_widths=False)`, the only
5
+ configuration v1 used. v2 adds an optional mean-pooled block -- the average of every word
6
+ embedding inside [start, end] inclusive -- gated by `mean_pool` (see notes/v2_plan.md item 3,
7
+ `configs/medbit_span_pooling/`). Simplified for the batch_size=1 case (see dataset.py): no batch
8
+ dimension, no span masking -- every document has an exact, un-padded list of spans.
9
+ """
10
+ import torch
11
+ from torch import nn
12
+
13
+
14
+ class EndpointSpanExtractor(nn.Module):
15
+ def __init__(self, input_dim: int, num_width_embeddings: int, span_width_embedding_dim: int,
16
+ mean_pool: bool = False):
17
+ super().__init__()
18
+ self.width_embedding = nn.Embedding(num_width_embeddings, span_width_embedding_dim)
19
+ self.mean_pool = mean_pool
20
+ self.output_dim = (3 if mean_pool else 2) * input_dim + span_width_embedding_dim
21
+
22
+ def get_output_dim(self) -> int:
23
+ return self.output_dim
24
+
25
+ def forward(self, word_embeddings: torch.Tensor, spans: torch.LongTensor) -> torch.Tensor:
26
+ """word_embeddings: (num_words, input_dim). spans: (num_spans, 2) inclusive [start, end]."""
27
+ starts, ends = spans[:, 0], spans[:, 1]
28
+ start_emb = word_embeddings[starts]
29
+ end_emb = word_embeddings[ends]
30
+ width_emb = self.width_embedding(ends - starts) # 0-indexed width (widths 1..N -> 0..N-1)
31
+ if not self.mean_pool:
32
+ return torch.cat([start_emb, end_emb, width_emb], dim=-1)
33
+ mean_emb = self._interior_mean(word_embeddings, starts, ends)
34
+ return torch.cat([start_emb, end_emb, mean_emb, width_emb], dim=-1)
35
+
36
+ @staticmethod
37
+ def _interior_mean(word_embeddings: torch.Tensor, starts: torch.LongTensor,
38
+ ends: torch.LongTensor) -> torch.Tensor:
39
+ """Mean of word_embeddings[start:end+1] per span, vectorized over the whole (bounded-
40
+ width) span list: gather a (num_spans, max_width) index grid and masked-average it. No
41
+ cumsum needed -- widths are capped by max_span_width (e.g. 12)."""
42
+ widths = ends - starts + 1 # (num_spans,)
43
+ max_width = int(widths.max().item())
44
+ offsets = torch.arange(max_width, device=word_embeddings.device).unsqueeze(0) # (1, W)
45
+ mask = offsets < widths.unsqueeze(1) # (num_spans, W)
46
+ idx = (starts.unsqueeze(1) + offsets).clamp(max=word_embeddings.size(0) - 1)
47
+ gathered = word_embeddings[idx] * mask.unsqueeze(-1) # (num_spans, W, input_dim)
48
+ return gathered.sum(dim=1) / widths.unsqueeze(1).to(word_embeddings.dtype)
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_basic_tokenize": true,
5
+ "do_lower_case": false,
6
+ "is_local": false,
7
+ "local_files_only": false,
8
+ "mask_token": "[MASK]",
9
+ "max_len": 512,
10
+ "model_max_length": 512,
11
+ "never_split": null,
12
+ "pad_token": "[PAD]",
13
+ "sep_token": "[SEP]",
14
+ "strip_accents": null,
15
+ "tokenize_chinese_chars": true,
16
+ "tokenizer_class": "BertTokenizer",
17
+ "truncation": true,
18
+ "unk_token": "[UNK]"
19
+ }
tokenizer_embedder.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Word-level embeddings from a subword transformer encoder.
2
+
3
+ Port of AllenNLP's `pretrained_transformer_mismatched` (indexer + embedder pair): tokenize
4
+ each word into subwords, run the transformer, mean-pool subwords back to one vector per word
5
+ (see training/README.md: word tokenization is fixed upstream, so the model always sees
6
+ word-level spans). Long documents are split into non-overlapping, word-aligned chunks of at
7
+ most `max_length` transformer positions (the medbit arm's "context folding": medBIT-r3-plus's
8
+ native window is 512 tokens, most reports are longer).
9
+
10
+ Deliberate deviation from v1: AllenNLP folds the flat *wordpiece* stream into fixed windows,
11
+ so a word's subwords can occasionally land split across two windows, encoded with no shared
12
+ context. Here chunks are packed at word granularity, so a word's subwords are always encoded
13
+ together -- strictly better representation quality at chunk boundaries, same architecture.
14
+
15
+ Each chunk is re-tokenized and special-token-wrapped through the tokenizer's own public
16
+ `__call__(..., is_split_into_words=True)` API rather than hand-rolling special-token insertion:
17
+ transformers 5.x dropped the old `build_inputs_with_special_tokens` / `prepare_for_model`
18
+ helpers from its fast-tokenizer wrapper (see training_v2/README.md), and going through the
19
+ stable public API is more robust across tokenizer families anyway.
20
+ """
21
+ import logging
22
+ from typing import List
23
+
24
+ import torch
25
+ from torch import nn
26
+ from transformers import AutoConfig, AutoModel, AutoTokenizer
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class MismatchedEmbedder(nn.Module):
32
+ def __init__(self, model_name: str, max_length: int, train_parameters: bool = True):
33
+ super().__init__()
34
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name)
35
+ # Built from config, not `AutoModel.from_pretrained`: this HF-packaged copy is only ever
36
+ # constructed either directly (fresh pretrained-encoder init, matching training_v2's
37
+ # behavior) or via `RadgraphModel.from_pretrained(...)`, which builds the whole module
38
+ # tree under a `torch.device("meta")` context and then loads our fine-tuned
39
+ # model.safetensors over it -- a nested `from_pretrained` call in that path hits
40
+ # transformers' "meta device anti-pattern" guard. Building from config sidesteps that
41
+ # and is equivalent here since the encoder weights get overwritten by the checkpoint
42
+ # load either way.
43
+ self.encoder = AutoModel.from_config(AutoConfig.from_pretrained(model_name))
44
+ self.max_length = max_length
45
+ self.output_dim = self.encoder.config.hidden_size
46
+ if not train_parameters:
47
+ for p in self.encoder.parameters():
48
+ p.requires_grad = False
49
+
50
+ num_specials = self.tokenizer.num_special_tokens_to_add(pair=False)
51
+ budget = max_length - num_specials
52
+ if budget <= 0:
53
+ raise ValueError(f"max_length={max_length} too small for {model_name}'s "
54
+ f"{num_specials} special tokens")
55
+ self._budget = budget
56
+
57
+ def get_output_dim(self) -> int:
58
+ return self.output_dim
59
+
60
+ def _word_subword_counts(self, words: List[str]) -> List[int]:
61
+ enc = self.tokenizer(words, is_split_into_words=True, add_special_tokens=False)
62
+ counts = [0] * len(words)
63
+ for w in enc.word_ids():
64
+ if w is not None:
65
+ counts[w] += 1
66
+ return counts
67
+
68
+ def _pack_chunks(self, counts: List[int]) -> List[List[int]]:
69
+ """Greedily group word indices into chunks whose total subword count fits the budget."""
70
+ chunks: List[List[int]] = []
71
+ current: List[int] = []
72
+ current_len = 0
73
+ for w, n in enumerate(counts):
74
+ n = max(n, 1)
75
+ if n > self._budget:
76
+ logger.warning("word %d has %d subwords > budget %d; it will be truncated "
77
+ "by the tokenizer", w, n, self._budget)
78
+ if current and current_len + n > self._budget:
79
+ chunks.append(current)
80
+ current, current_len = [], 0
81
+ current.append(w)
82
+ current_len += n
83
+ if current:
84
+ chunks.append(current)
85
+ return chunks
86
+
87
+ def forward(self, words: List[str]) -> torch.Tensor:
88
+ """Returns (num_words, hidden_size)."""
89
+ device = next(self.encoder.parameters()).device
90
+ counts = self._word_subword_counts(words)
91
+ chunks = self._pack_chunks(counts)
92
+
93
+ word_embeddings: List[torch.Tensor] = [None] * len(words) # type: ignore[list-item]
94
+ for chunk_word_ixs in chunks:
95
+ chunk_words = [words[i] for i in chunk_word_ixs]
96
+ enc = self.tokenizer(chunk_words, is_split_into_words=True, add_special_tokens=True,
97
+ truncation=True, max_length=self.max_length, return_tensors="pt")
98
+ input_ids = enc["input_ids"].to(device)
99
+ attention_mask = enc["attention_mask"].to(device)
100
+ hidden = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state[0]
101
+
102
+ word_ids = enc.word_ids(batch_index=0)
103
+ positions_by_word: dict = {}
104
+ for pos, w in enumerate(word_ids):
105
+ if w is not None:
106
+ positions_by_word.setdefault(w, []).append(pos)
107
+ for local_w, positions in positions_by_word.items():
108
+ word_embeddings[chunk_word_ixs[local_w]] = hidden[positions].mean(dim=0)
109
+
110
+ for i, emb in enumerate(word_embeddings):
111
+ if emb is None: # word tokenized to zero subwords (rare; e.g. an unknown char)
112
+ word_embeddings[i] = hidden.new_zeros(self.output_dim)
113
+
114
+ return torch.stack(word_embeddings, dim=0)
transformer_block.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transformer-block classifier body: self-attention across the span dimension, used by
2
+ ner_head.py / relation_head.py in the `medbit_transformer` arm in place of the plain
3
+ `FeedForward` scorer (see notes/v2_plan.md item 1).
4
+
5
+ A standard `nn.TransformerEncoderLayer` already bundles self-attention with its own
6
+ position-wise FFN sublayer (residual + norm around both), so this fully replaces
7
+ `FeedForward` rather than wrapping it -- no separate downstream MLP survives; callers add
8
+ only a final `nn.Linear` to project to label logits, same shape as today.
9
+
10
+ Spans are the "sequence" here (length = number of candidate/pruned spans, not number of
11
+ words); there is no batch dimension and no padding mask, matching every other module's
12
+ batch_size=1 simplification (see pruner.py, span_extractor.py). A sinusoidal positional
13
+ encoding keyed on each span's start-word index gives attention a notion of span order/distance
14
+ -- the same choice already made in v2_plan.md item 1 for the (superseded) word-level
15
+ context_layer idea, just applied to span positions instead of word positions.
16
+ """
17
+ import math
18
+
19
+ import torch
20
+ from torch import nn
21
+
22
+
23
+ def sinusoidal_position_encoding(positions: torch.Tensor, d_model: int) -> torch.Tensor:
24
+ """positions: (N,) int64 span start indices. Returns (N, d_model)."""
25
+ div_term = torch.exp(torch.arange(0, d_model, 2, device=positions.device, dtype=torch.float32) *
26
+ (-math.log(10000.0) / d_model))
27
+ angles = positions.unsqueeze(1).to(torch.float32) * div_term.unsqueeze(0) # (N, d_model/2)
28
+ pe = torch.zeros(positions.size(0), d_model, device=positions.device)
29
+ pe[:, 0::2] = torch.sin(angles)
30
+ pe[:, 1::2] = torch.cos(angles[:, :pe[:, 1::2].size(1)])
31
+ return pe
32
+
33
+
34
+ class TransformerBlock(nn.Module):
35
+ def __init__(self, input_dim: int, d_model: int, nhead: int, num_layers: int,
36
+ dim_feedforward: int, dropout: float, activation: str = "gelu"):
37
+ super().__init__()
38
+ self.input_proj = nn.Linear(input_dim, d_model) if input_dim != d_model else nn.Identity()
39
+ layer = nn.TransformerEncoderLayer(
40
+ d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward, dropout=dropout,
41
+ activation=activation, norm_first=True, batch_first=True)
42
+ self.encoder = nn.TransformerEncoder(layer, num_layers=num_layers, enable_nested_tensor=False)
43
+ self.d_model = d_model
44
+ self.output_dim = d_model
45
+
46
+ def forward(self, span_embeddings: torch.Tensor, start_positions: torch.Tensor) -> torch.Tensor:
47
+ """span_embeddings: (num_spans, input_dim). start_positions: (num_spans,) int64.
48
+ No batch dim on the way in/out -- one fake batch of size 1 for nn.TransformerEncoder."""
49
+ x = self.input_proj(span_embeddings) + sinusoidal_position_encoding(start_positions, self.d_model)
50
+ return self.encoder(x.unsqueeze(0)).squeeze(0)
vocab.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal label vocabulary: per-namespace string<->int maps, null label pinned to index 0.
2
+
3
+ Port of the one piece of AllenNLP's `Vocabulary` this project actually uses: separate NER /
4
+ relation label namespaces per `dataset` value (e.g. "radgraph-it__ner_labels"), with a
5
+ non-embedded null label "" always at index 0 so "argmax == 0" means "no label" throughout the
6
+ model. Built once from the training split and saved alongside the checkpoint so dev/test/
7
+ inference use the exact same label<->index mapping.
8
+ """
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Dict, Iterable, List
12
+
13
+ NULL_LABEL = ""
14
+
15
+
16
+ class Vocabulary:
17
+ def __init__(self):
18
+ self._token_to_index: Dict[str, Dict[str, int]] = {}
19
+ self._index_to_token: Dict[str, Dict[int, str]] = {}
20
+
21
+ def add_namespace(self, namespace: str) -> None:
22
+ if namespace not in self._token_to_index:
23
+ self._token_to_index[namespace] = {NULL_LABEL: 0}
24
+ self._index_to_token[namespace] = {0: NULL_LABEL}
25
+
26
+ def add_token(self, token: str, namespace: str) -> int:
27
+ self.add_namespace(namespace)
28
+ t2i = self._token_to_index[namespace]
29
+ if token not in t2i:
30
+ idx = len(t2i)
31
+ t2i[token] = idx
32
+ self._index_to_token[namespace][idx] = token
33
+ return t2i[token]
34
+
35
+ def get_token_index(self, token: str, namespace: str) -> int:
36
+ return self._token_to_index[namespace][token]
37
+
38
+ def get_token_from_index(self, index: int, namespace: str) -> str:
39
+ return self._index_to_token[namespace][index]
40
+
41
+ def get_vocab_size(self, namespace: str) -> int:
42
+ return len(self._token_to_index[namespace])
43
+
44
+ def get_namespaces(self) -> List[str]:
45
+ return list(self._token_to_index.keys())
46
+
47
+ @classmethod
48
+ def from_documents(cls, documents: Iterable, ner_suffix="ner_labels",
49
+ relation_suffix="relation_labels") -> "Vocabulary":
50
+ vocab = cls()
51
+ for doc in documents:
52
+ ner_ns = f"{doc.dataset}__{ner_suffix}"
53
+ rel_ns = f"{doc.dataset}__{relation_suffix}"
54
+ vocab.add_namespace(ner_ns)
55
+ vocab.add_namespace(rel_ns)
56
+ for sent in doc.sentences:
57
+ for e in sent.ner or []:
58
+ vocab.add_token(e.label, ner_ns)
59
+ for r in sent.relations or []:
60
+ vocab.add_token(r.label, rel_ns)
61
+ return vocab
62
+
63
+ def to_dict(self) -> dict:
64
+ return {ns: dict(sorted(t2i.items(), key=lambda kv: kv[1]))
65
+ for ns, t2i in self._token_to_index.items()}
66
+
67
+ def save(self, path: str) -> None:
68
+ Path(path).write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2))
69
+
70
+ @classmethod
71
+ def load(cls, path: str) -> "Vocabulary":
72
+ vocab = cls()
73
+ for ns, t2i in json.loads(Path(path).read_text()).items():
74
+ vocab.add_namespace(ns)
75
+ for token, idx in sorted(t2i.items(), key=lambda kv: kv[1]):
76
+ if token != NULL_LABEL:
77
+ vocab.add_token(token, ns)
78
+ return vocab