Feature Extraction
Transformers
Safetensors
Italian
radgraph_it
radiology
information-extraction
named-entity-recognition
relation-extraction
medical
radgraph
custom_code
Instructions to use radgraphIT/Radgraph-IT with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use radgraphIT/Radgraph-IT with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="radgraphIT/Radgraph-IT", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("radgraphIT/Radgraph-IT", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add transformers-compatible wrapper (AutoModel/AutoTokenizer via trust_remote_code)
Browse files- README.md +65 -0
- __init__.py +0 -0
- config.json +49 -0
- configuration_radgraph.py +65 -0
- dataset.py +123 -0
- document.py +254 -0
- feedforward.py +23 -0
- metrics.py +91 -0
- model.py +82 -0
- model.safetensors +3 -0
- modeling_radgraph.py +107 -0
- ner_head.py +99 -0
- predictor.py +25 -0
- pruner.py +26 -0
- relation_head.py +203 -0
- span_extractor.py +48 -0
- tokenizer.json +0 -0
- tokenizer_config.json +19 -0
- tokenizer_embedder.py +114 -0
- transformer_block.py +50 -0
- vocab.py +78 -0
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
|