RadGraph-IT 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. radgraph_it-1.0.0.dist-info/METADATA +159 -0
  2. radgraph_it-1.0.0.dist-info/RECORD +39 -0
  3. radgraph_it-1.0.0.dist-info/WHEEL +4 -0
  4. radgraph_it-1.0.0.dist-info/entry_points.txt +2 -0
  5. radgraph_it-1.0.0.dist-info/licenses/LICENSE +23 -0
  6. radgraph_it-1.0.0.dist-info/licenses/LICENSES/Third-Party.md +41 -0
  7. radgraphit/__init__.py +47 -0
  8. radgraphit/api.py +168 -0
  9. radgraphit/artifacts/__init__.py +5 -0
  10. radgraphit/artifacts/manifest.py +290 -0
  11. radgraphit/artifacts/registry.py +24 -0
  12. radgraphit/artifacts/resolver.py +200 -0
  13. radgraphit/cli.py +131 -0
  14. radgraphit/core/__init__.py +1 -0
  15. radgraphit/core/errors.py +46 -0
  16. radgraphit/core/models.py +116 -0
  17. radgraphit/inference/__init__.py +5 -0
  18. radgraphit/inference/backends/__init__.py +1 -0
  19. radgraphit/inference/backends/base.py +13 -0
  20. radgraphit/inference/backends/dygie_v2/__init__.py +11 -0
  21. radgraphit/inference/backends/dygie_v2/backend.py +32 -0
  22. radgraphit/inference/backends/dygie_v2/device.py +46 -0
  23. radgraphit/inference/backends/dygie_v2/feedforward.py +25 -0
  24. radgraphit/inference/backends/dygie_v2/loader.py +165 -0
  25. radgraphit/inference/backends/dygie_v2/model.py +101 -0
  26. radgraphit/inference/backends/dygie_v2/ner_head.py +75 -0
  27. radgraphit/inference/backends/dygie_v2/pruner.py +27 -0
  28. radgraphit/inference/backends/dygie_v2/relation_head.py +147 -0
  29. radgraphit/inference/backends/dygie_v2/span_extractor.py +51 -0
  30. radgraphit/inference/backends/dygie_v2/spans.py +20 -0
  31. radgraphit/inference/backends/dygie_v2/tokenizer_embedder.py +123 -0
  32. radgraphit/inference/backends/dygie_v2/transformer_block.py +62 -0
  33. radgraphit/inference/backends/dygie_v2/vocab.py +123 -0
  34. radgraphit/inference/decoder.py +53 -0
  35. radgraphit/inference/service.py +80 -0
  36. radgraphit/inference/tokenizer.py +60 -0
  37. radgraphit/py.typed +0 -0
  38. radgraphit/serialization/__init__.py +15 -0
  39. radgraphit/serialization/radgraph_xl.py +135 -0
@@ -0,0 +1,46 @@
1
+ """Domain exception hierarchy for predictable public loading and inference failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RadGraphITError(Exception):
7
+ """Base class for every error raised by radgraphit."""
8
+
9
+
10
+ class InvalidReportError(RadGraphITError, ValueError):
11
+ """Input is not a non-empty ``str`` or non-empty sequence of non-empty ``str``.
12
+
13
+ Raised deterministically instead of the upstream package's behaviour of silently turning an
14
+ empty report into the literal string ``"None"``.
15
+ """
16
+
17
+
18
+ class ModelConfigurationError(RadGraphITError):
19
+ """The model artifact could not be located or is misconfigured."""
20
+
21
+
22
+ class ModelNotConfiguredError(ModelConfigurationError):
23
+ """The caller supplied an empty model identifier or revision."""
24
+
25
+
26
+ class ModelNotFoundError(ModelConfigurationError):
27
+ """A configured model id / revision could not be downloaded from the Hugging Face Hub, or a
28
+ local model directory does not exist or is missing required files."""
29
+
30
+
31
+ class ManifestError(ModelConfigurationError):
32
+ """``model_manifest.json`` is missing, malformed, incompatible, or fails checksum
33
+ verification."""
34
+
35
+
36
+ class DeviceError(RadGraphITError):
37
+ """An invalid device string was requested, or CUDA was requested but is unavailable."""
38
+
39
+
40
+ class CheckpointError(ModelConfigurationError):
41
+ """The checkpoint could not be loaded onto the reconstructed model topology (e.g. a
42
+ ``strict=True`` state-dict mismatch)."""
43
+
44
+
45
+ class BackendError(RadGraphITError):
46
+ """The inference backend named by the manifest is unknown or unsupported."""
@@ -0,0 +1,116 @@
1
+ """Immutable, typed value objects that flow through the inference pipeline.
2
+
3
+ These are plain ``@dataclass(frozen=True)`` objects (no torch, no I/O) so the pure boundary —
4
+ tokenizer -> backend -> decoder -> serializer — can be reasoned about and unit-tested without a
5
+ model. Token indices everywhere are **token-level, zero-based, and inclusive** (``end_ix`` is the
6
+ last token *in* the span), exactly like RadGraph-XL; there are no character offsets.
7
+
8
+ The typed protocols (:class:`InferenceBackend`) live here too, kept intentionally small: the only
9
+ thing the service needs from a backend is "given tokens, give me raw scored predictions".
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Protocol, runtime_checkable
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class TokenizedReport:
20
+ """A report after RadGraph-XL-compatible normalization + tokenization.
21
+
22
+ ``tokens`` are the normalized word tokens handed verbatim to the backend; ``text`` is their
23
+ space-joined form (the ``"text"`` field of the RadGraph-XL output).
24
+ """
25
+
26
+ tokens: tuple[str, ...]
27
+
28
+ @property
29
+ def text(self) -> str:
30
+ return " ".join(self.tokens)
31
+
32
+ def __len__(self) -> int:
33
+ return len(self.tokens)
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ScoredEntity:
38
+ """One raw NER prediction: an inclusive ``[start_ix, end_ix]`` span with a label and scores."""
39
+
40
+ start_ix: int
41
+ end_ix: int
42
+ label: str
43
+ raw_score: float
44
+ softmax_score: float
45
+
46
+ @property
47
+ def span(self) -> tuple[int, int]:
48
+ return (self.start_ix, self.end_ix)
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ScoredRelation:
53
+ """One raw relation prediction, **directed** source -> target.
54
+
55
+ Endpoints are referenced by span (not entity id); the serializer resolves them to entity ids.
56
+ """
57
+
58
+ label: str
59
+ source_start: int
60
+ source_end: int
61
+ target_start: int
62
+ target_end: int
63
+ raw_score: float
64
+ softmax_score: float
65
+
66
+ @property
67
+ def source_span(self) -> tuple[int, int]:
68
+ return (self.source_start, self.source_end)
69
+
70
+ @property
71
+ def target_span(self) -> tuple[int, int]:
72
+ return (self.target_start, self.target_end)
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ReportPrediction:
77
+ """The full raw graph predicted for a single report, before RadGraph-XL serialization.
78
+
79
+ This is the internal, typed result. The public API serializes it to the RadGraph-XL dict;
80
+ advanced callers can request these objects directly (see ``RadGraphIT.predict_graphs``).
81
+ """
82
+
83
+ doc_key: str
84
+ tokens: tuple[str, ...]
85
+ entities: tuple[ScoredEntity, ...]
86
+ relations: tuple[ScoredRelation, ...]
87
+
88
+ @property
89
+ def text(self) -> str:
90
+ return " ".join(self.tokens)
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class RawBackendOutput:
95
+ """The minimal, torch-free contract a backend returns for one report.
96
+
97
+ NER tuples are ``(start, end, label, raw_score, softmax_score)`` and relation tuples are
98
+ ``(s1, e1, s2, e2, label, raw_score, softmax_score)`` — the same shape upstream DyGIE decode
99
+ produced, kept as plain Python so nothing torch-shaped leaks past the backend boundary.
100
+ """
101
+
102
+ ner: tuple[tuple[int, int, str, float, float], ...]
103
+ relations: tuple[tuple[int, int, int, int, str, float, float], ...]
104
+
105
+
106
+ @runtime_checkable
107
+ class InferenceBackend(Protocol):
108
+ """What :class:`~radgraphit.inference.service.InferenceService` needs from any backend.
109
+
110
+ Deliberately tiny: one report's tokens in, one :class:`RawBackendOutput` out. This is the seam
111
+ a fake backend is injected at in tests, and the boundary that keeps torch out of the service.
112
+ """
113
+
114
+ def infer(self, tokens: tuple[str, ...]) -> RawBackendOutput:
115
+ """Run inference on a single report's tokens and return raw scored predictions."""
116
+ ...
@@ -0,0 +1,5 @@
1
+ """Inference orchestration: tokenizer, backend boundary, decoder, and the service.
2
+
3
+ Importing this package is torch-free; the torch-dependent DyGIE-v2 backend under
4
+ ``backends/dygie_v2`` is imported lazily by the loader, never at package import time.
5
+ """
@@ -0,0 +1 @@
1
+ """Inference backends. The base protocol is torch-free; concrete backends may pull in torch."""
@@ -0,0 +1,13 @@
1
+ """The backend-facing surface of the inference protocol.
2
+
3
+ A backend is anything that can turn one report's tokens into raw scored predictions. The protocol
4
+ itself is defined in :mod:`radgraphit.core.models` (so it stays torch-free and importable
5
+ everywhere); it is re-exported here as the canonical import for backend authors and for the
6
+ service. Concrete backends (e.g. ``dygie_v2``) live in sibling packages and may depend on torch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ...core.models import InferenceBackend, RawBackendOutput
12
+
13
+ __all__ = ["InferenceBackend", "RawBackendOutput"]
@@ -0,0 +1,11 @@
1
+ """DyGIE-v2 backend: an inference-only PyTorch port of ``training_v2/src/dygie``.
2
+
3
+ Private to the package — none of these classes are part of the public API. The parameter-bearing
4
+ modules are faithful ports of the training code so a v2 checkpoint loads with ``strict=True``.
5
+ Importing this package pulls in torch + transformers; it is imported lazily by the API facade.
6
+ """
7
+
8
+ from .backend import DyGIEv2Backend
9
+ from .loader import load_backend
10
+
11
+ __all__ = ["DyGIEv2Backend", "load_backend"]
@@ -0,0 +1,32 @@
1
+ """The ``dygie_v2`` inference backend: a thin, torch-free-facing wrapper over the ported model.
2
+
3
+ Implements the :class:`~radgraphit.core.models.InferenceBackend` protocol: tokens in, raw scored
4
+ tuples out. All torch lives inside; nothing torch-shaped crosses ``infer``'s boundary.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import torch
10
+
11
+ from ....core.errors import BackendError
12
+ from ....core.models import RawBackendOutput
13
+ from .model import DyGIEModel
14
+
15
+
16
+ class DyGIEv2Backend:
17
+ """Runs a loaded :class:`DyGIEModel` on one report at a time."""
18
+
19
+ def __init__(self, model: DyGIEModel, device: torch.device):
20
+ self._model = model
21
+ self._device = device
22
+
23
+ @property
24
+ def device(self) -> torch.device:
25
+ return self._device
26
+
27
+ def infer(self, tokens: tuple[str, ...]) -> RawBackendOutput:
28
+ try:
29
+ ner, relations = self._model.predict(tokens)
30
+ except Exception as exc:
31
+ raise BackendError(f"dygie_v2 inference failed: {exc}") from exc
32
+ return RawBackendOutput(ner=tuple(ner), relations=tuple(relations))
@@ -0,0 +1,46 @@
1
+ """Explicit device selection with clear errors and a working CPU default.
2
+
3
+ Accepts ``"auto"`` (default), ``"cpu"``, ``"cuda"``, or ``"cuda:<index>"``. ``"auto"`` picks CUDA
4
+ when available and otherwise falls back to CPU. An explicit CUDA request when CUDA is unavailable
5
+ is an error (no silent fallback) — but CPU always works.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ from ....core.errors import DeviceError
13
+
14
+
15
+ def resolve_device(device: str | None) -> torch.device:
16
+ """Resolve a device string to a concrete :class:`torch.device`.
17
+
18
+ Raises :class:`DeviceError` for an unknown string or a CUDA request on a machine without CUDA.
19
+ """
20
+ if device is None or device == "auto":
21
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
22
+ if not isinstance(device, str):
23
+ raise DeviceError(f"device must be a string or None, got {type(device).__name__}.")
24
+ if device == "cpu":
25
+ return torch.device("cpu")
26
+ if device == "cuda" or device.startswith("cuda:"):
27
+ if not torch.cuda.is_available():
28
+ raise DeviceError(
29
+ f"Device {device!r} was requested but CUDA is not available. Use device='cpu' or "
30
+ "device='auto' (which falls back to CPU)."
31
+ )
32
+ index = 0
33
+ if ":" in device:
34
+ suffix = device.split(":", 1)[1]
35
+ if not suffix.isdigit():
36
+ raise DeviceError(f"Invalid CUDA device index in {device!r}.")
37
+ index = int(suffix)
38
+ if index >= torch.cuda.device_count():
39
+ raise DeviceError(
40
+ f"CUDA device index {index} out of range: only {torch.cuda.device_count()} "
41
+ "CUDA device(s) visible."
42
+ )
43
+ return torch.device(f"cuda:{index}")
44
+ raise DeviceError(
45
+ f"Unknown device {device!r}. Expected one of: 'auto', 'cpu', 'cuda', 'cuda:<index>'."
46
+ )
@@ -0,0 +1,25 @@
1
+ """Shared MLP scorer body — faithful port of ``training_v2/src/dygie/feedforward.py``.
2
+
3
+ Structure (the ``.net`` Sequential and its layer indices) is preserved exactly so the parameter
4
+ names in the state dict match the checkpoint.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import torch
10
+ from torch import nn
11
+
12
+
13
+ class FeedForward(nn.Module):
14
+ def __init__(self, input_dim: int, hidden_dims: list[int], dropout: float):
15
+ super().__init__()
16
+ layers: list[nn.Module] = []
17
+ prev = input_dim
18
+ for dim in hidden_dims:
19
+ layers += [nn.Linear(prev, dim), nn.ReLU(), nn.Dropout(dropout)]
20
+ prev = dim
21
+ self.net = nn.Sequential(*layers)
22
+ self.output_dim = prev
23
+
24
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
25
+ return self.net(x)
@@ -0,0 +1,165 @@
1
+ """Load a v2 checkpoint into a runnable backend.
2
+
3
+ Reconstructs the model topology from ``config.json`` + ``vocab.json``, then loads the weights with
4
+ ``strict=True`` so any mismatch between the checkpoint and the reconstructed graph fails loudly and
5
+ comprehensibly. For ``.pt`` checkpoints the safe ``torch.load(..., weights_only=True)`` path is
6
+ used; ``.safetensors`` is supported (and preferred for a future release) when present.
7
+
8
+ This is the only module that reads the config schema. It accepts every option v2 configs can carry
9
+ — ``encoder``, ``max_span_width``, ``feature_size``, ``feedforward_params``, ``loss_weights`` (read
10
+ but not needed at inference), ``relation_spans_per_word``, and, when present, ``span_pooling``,
11
+ ``transformer_params``, ``relation_context``, ``relation_feedforward_params``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import torch
22
+
23
+ from ....core.errors import CheckpointError
24
+ from .backend import DyGIEv2Backend
25
+ from .device import resolve_device
26
+ from .model import DyGIEModel
27
+ from .tokenizer_embedder import MismatchedEmbedder
28
+ from .vocab import Vocabulary, derive_dataset
29
+
30
+ logger = logging.getLogger("radgraphit")
31
+
32
+
33
+ def build_model(
34
+ config: dict[str, Any],
35
+ vocab: Vocabulary,
36
+ dataset: str,
37
+ *,
38
+ encoder_revision: str | None = None,
39
+ embedder: MismatchedEmbedder | None = None,
40
+ ) -> DyGIEModel:
41
+ """Reconstruct the model topology from a v2 config dict + loaded vocab.
42
+
43
+ ``embedder`` is injectable for testing (to avoid downloading an encoder); production leaves it
44
+ ``None`` so the default :class:`MismatchedEmbedder` is built from ``config["encoder"]``.
45
+ """
46
+ try:
47
+ encoder = config["encoder"]
48
+ model = DyGIEModel(
49
+ vocab,
50
+ dataset,
51
+ encoder_name=encoder["model_name"],
52
+ encoder_revision=encoder_revision,
53
+ max_length=encoder["max_length"],
54
+ max_span_width=config["max_span_width"],
55
+ feature_size=config["feature_size"],
56
+ feedforward_params=config["feedforward_params"],
57
+ relation_spans_per_word=config["relation_spans_per_word"],
58
+ span_pooling=config.get("span_pooling", False),
59
+ transformer_params=config.get("transformer_params"),
60
+ relation_context=config.get("relation_context", False),
61
+ relation_feedforward_params=config.get("relation_feedforward_params"),
62
+ train_encoder=encoder.get("train_parameters", True),
63
+ embedder=embedder,
64
+ )
65
+ except (KeyError, TypeError, ValueError) as exc:
66
+ raise CheckpointError(
67
+ f"config.json is missing or has an invalid required field: {exc}. A valid v2 config "
68
+ "needs at least "
69
+ "'encoder', 'max_span_width', 'feature_size', 'feedforward_params', and "
70
+ "'relation_spans_per_word'."
71
+ ) from exc
72
+ except Exception as exc:
73
+ raise CheckpointError(
74
+ f"Could not construct the encoder/model topology described by config.json: {exc}"
75
+ ) from exc
76
+ return model
77
+
78
+
79
+ def _read_state_dict(weights_path: Path) -> dict[str, torch.Tensor]:
80
+ """Read a state dict from a ``.pt`` (safe, weights-only) or ``.safetensors`` checkpoint."""
81
+ if weights_path.suffix == ".safetensors":
82
+ try:
83
+ from safetensors.torch import load_file
84
+ except ImportError as exc: # pragma: no cover - exercised only when safetensors is absent
85
+ raise CheckpointError(
86
+ f"{weights_path.name} is a safetensors checkpoint but the 'safetensors' package is "
87
+ "not installed. Install radgraphit[safetensors]."
88
+ ) from exc
89
+ try:
90
+ state_dict = load_file(str(weights_path))
91
+ except Exception as exc:
92
+ raise CheckpointError(
93
+ f"Could not read safetensors checkpoint {weights_path}: {exc}"
94
+ ) from exc
95
+ return state_dict
96
+
97
+ # .pt path: weights_only=True refuses to execute arbitrary pickled code (safe for untrusted
98
+ # bundles). The v2 trainer saves {"epoch": int, "model_state_dict": OrderedDict[str, Tensor]}.
99
+ try:
100
+ obj = torch.load(str(weights_path), map_location="cpu", weights_only=True)
101
+ except Exception as exc:
102
+ raise CheckpointError(f"Could not safely read checkpoint {weights_path}: {exc}") from exc
103
+ if isinstance(obj, dict) and "model_state_dict" in obj:
104
+ obj = obj["model_state_dict"]
105
+ if not isinstance(obj, dict) or not all(
106
+ isinstance(key, str) and isinstance(value, torch.Tensor) for key, value in obj.items()
107
+ ):
108
+ raise CheckpointError(
109
+ f"Unexpected checkpoint structure in {weights_path.name}: expected a mapping from "
110
+ "parameter names to tensors, optionally under 'model_state_dict'."
111
+ )
112
+ return obj
113
+
114
+
115
+ def load_weights(model: DyGIEModel, weights_path: Path) -> None:
116
+ """Load weights into ``model`` with ``strict=True``, raising a clear error on mismatch."""
117
+ state_dict = _read_state_dict(weights_path)
118
+ try:
119
+ model.load_state_dict(state_dict, strict=True)
120
+ except Exception as exc:
121
+ raise CheckpointError(
122
+ f"Failed to load {weights_path.name} into the reconstructed model (strict=True): "
123
+ f"{exc}. This usually means config.json / vocab.json do not describe the same "
124
+ "architecture the checkpoint was trained with."
125
+ ) from exc
126
+
127
+
128
+ def load_backend(
129
+ *,
130
+ config_path: Path,
131
+ vocab_path: Path,
132
+ weights_path: Path,
133
+ device: str = "auto",
134
+ encoder_revision: str | None = None,
135
+ embedder: MismatchedEmbedder | None = None,
136
+ ) -> DyGIEv2Backend:
137
+ """Build and return a ready-to-run :class:`DyGIEv2Backend` from a verified bundle's files."""
138
+ try:
139
+ config = json.loads(config_path.read_text(encoding="utf-8"))
140
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
141
+ raise CheckpointError(
142
+ f"Could not read a valid JSON config at {config_path}: {exc}"
143
+ ) from exc
144
+ if not isinstance(config, dict):
145
+ raise CheckpointError(f"config.json at {config_path} must contain a JSON object.")
146
+ vocab = Vocabulary.load(vocab_path)
147
+ dataset = derive_dataset(vocab)
148
+ torch_device = resolve_device(device)
149
+
150
+ model = build_model(
151
+ config,
152
+ vocab,
153
+ dataset,
154
+ encoder_revision=encoder_revision,
155
+ embedder=embedder,
156
+ )
157
+ load_weights(model, weights_path)
158
+ try:
159
+ model.to(torch_device).eval()
160
+ except Exception as exc:
161
+ raise CheckpointError(
162
+ f"Could not move the reconstructed model to {torch_device}: {exc}"
163
+ ) from exc
164
+ logger.debug("Loaded dygie_v2 backend (dataset=%s) onto %s", dataset, torch_device)
165
+ return DyGIEv2Backend(model, torch_device)
@@ -0,0 +1,101 @@
1
+ """The joint model — inference-only port of ``training_v2/src/dygie/model.py``.
2
+
3
+ Wires ``embedder -> span_extractor -> ner + relation``. The four submodule names (``embedder``,
4
+ ``span_extractor``, ``ner``, ``relation``) and everything beneath them are preserved exactly, so
5
+ ``state_dict()`` keys line up with the training checkpoint and ``load_state_dict(strict=True)``
6
+ succeeds. The training-only pieces (loss weighting, xavier re-init, metrics) are gone — every
7
+ parameter is overwritten by the checkpoint at load time anyway.
8
+
9
+ ``embedder`` is injectable purely so the loader can be unit-tested without downloading an encoder;
10
+ production always uses the default :class:`MismatchedEmbedder`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import torch
16
+ from torch import nn
17
+
18
+ from .ner_head import NERHead, NerTuple
19
+ from .relation_head import RelationHead, RelationTuple
20
+ from .span_extractor import EndpointSpanExtractor
21
+ from .spans import enumerate_spans
22
+ from .tokenizer_embedder import MismatchedEmbedder
23
+ from .vocab import Vocabulary
24
+
25
+
26
+ class DyGIEModel(nn.Module):
27
+ def __init__(
28
+ self,
29
+ vocab: Vocabulary,
30
+ dataset: str,
31
+ *,
32
+ encoder_name: str,
33
+ encoder_revision: str | None,
34
+ max_length: int,
35
+ max_span_width: int,
36
+ feature_size: int,
37
+ feedforward_params: dict,
38
+ relation_spans_per_word: float,
39
+ span_pooling: bool = False,
40
+ transformer_params: dict | None = None,
41
+ relation_context: bool = False,
42
+ relation_feedforward_params: dict | None = None,
43
+ train_encoder: bool = True,
44
+ embedder: MismatchedEmbedder | None = None,
45
+ ):
46
+ super().__init__()
47
+ self.vocab = vocab
48
+ self.dataset = dataset
49
+ self.max_span_width = max_span_width
50
+
51
+ self.embedder = (
52
+ embedder
53
+ if embedder is not None
54
+ else MismatchedEmbedder(
55
+ encoder_name,
56
+ max_length,
57
+ train_encoder,
58
+ revision=encoder_revision,
59
+ )
60
+ )
61
+ self.span_extractor = EndpointSpanExtractor(
62
+ self.embedder.get_output_dim(),
63
+ num_width_embeddings=max_span_width,
64
+ span_width_embedding_dim=feature_size,
65
+ mean_pool=span_pooling,
66
+ )
67
+ span_emb_dim = self.span_extractor.get_output_dim()
68
+
69
+ self.ner = NERHead(vocab, span_emb_dim, feedforward_params, transformer_params)
70
+ self.relation = RelationHead(
71
+ vocab,
72
+ span_emb_dim,
73
+ self.embedder.get_output_dim(),
74
+ feedforward_params,
75
+ relation_spans_per_word,
76
+ transformer_params,
77
+ relation_context,
78
+ relation_feedforward_params,
79
+ )
80
+
81
+ @torch.no_grad()
82
+ def predict(self, tokens: tuple[str, ...]) -> tuple[list[NerTuple], list[RelationTuple]]:
83
+ """Run NER + relation inference on one report's tokens.
84
+
85
+ Returns ``(ner_predictions, relation_predictions)`` as plain tuples. Assumes a non-empty
86
+ token list (the service guarantees this).
87
+ """
88
+ self.eval()
89
+ words = list(tokens)
90
+ spans = enumerate_spans(words, self.max_span_width)
91
+ device = next(self.parameters()).device
92
+
93
+ word_embeddings = self.embedder(words)
94
+ spans_tensor = torch.tensor(spans, dtype=torch.long, device=device)
95
+ span_embeddings = self.span_extractor(word_embeddings, spans_tensor)
96
+
97
+ ner = self.ner.predict(self.dataset, spans, span_embeddings)
98
+ relations = self.relation.predict(
99
+ self.dataset, spans, span_embeddings, len(words), word_embeddings
100
+ )
101
+ return ner, relations
@@ -0,0 +1,75 @@
1
+ """NER head — inference-only port of ``training_v2/src/dygie/ner_head.py``.
2
+
3
+ The parameter-bearing structure (``scorers`` + ``classifiers`` ModuleDicts, and the null-column
4
+ trick: score ``n_labels - 1`` real classes, prepend a fixed-0 null column) is preserved exactly so
5
+ the state dict matches the checkpoint. Loss and metrics are dropped — inference only needs the
6
+ decode step, which returns plain ``(start, end, label, raw_score, softmax_score)`` tuples.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ from torch import nn
13
+
14
+ from .feedforward import FeedForward
15
+ from .transformer_block import TransformerBlock
16
+ from .vocab import Vocabulary
17
+
18
+ NerTuple = tuple[int, int, str, float, float]
19
+
20
+
21
+ class NERHead(nn.Module):
22
+ def __init__(
23
+ self,
24
+ vocab: Vocabulary,
25
+ span_emb_dim: int,
26
+ feedforward_params: dict,
27
+ transformer_params: dict | None = None,
28
+ ):
29
+ super().__init__()
30
+ self.vocab = vocab
31
+ self.namespaces = [ns for ns in vocab.get_namespaces() if ns.endswith("ner_labels")]
32
+ self.n_labels = {ns: vocab.get_vocab_size(ns) for ns in self.namespaces}
33
+ self.use_transformer = transformer_params is not None
34
+
35
+ self.scorers = nn.ModuleDict()
36
+ self.classifiers = nn.ModuleDict()
37
+ for ns in self.namespaces:
38
+ if self.use_transformer:
39
+ assert transformer_params is not None
40
+ block = TransformerBlock(span_emb_dim, **transformer_params)
41
+ self.scorers[ns] = block
42
+ body_out = block.output_dim
43
+ else:
44
+ ff = FeedForward(
45
+ span_emb_dim, feedforward_params["hidden_dims"], feedforward_params["dropout"]
46
+ )
47
+ self.scorers[ns] = ff
48
+ body_out = ff.output_dim
49
+ self.classifiers[ns] = nn.Linear(body_out, self.n_labels[ns] - 1)
50
+
51
+ def predict(
52
+ self, dataset: str, spans: list[tuple[int, int]], span_embeddings: torch.Tensor
53
+ ) -> list[NerTuple]:
54
+ ns = f"{dataset}__ner_labels"
55
+ if self.use_transformer:
56
+ starts = torch.tensor(
57
+ [s[0] for s in spans], dtype=torch.long, device=span_embeddings.device
58
+ )
59
+ hidden = self.scorers[ns](span_embeddings, starts)
60
+ else:
61
+ hidden = self.scorers[ns](span_embeddings)
62
+ scores = self.classifiers[ns](hidden) # (num_spans, n_labels - 1)
63
+ dummy = scores.new_zeros(scores.size(0), 1) # null-label score, fixed at 0
64
+ scores = torch.cat([dummy, scores], dim=-1) # (num_spans, n_labels)
65
+
66
+ softmax_scores = torch.softmax(scores, dim=-1)
67
+ raw, predicted = scores.max(dim=-1)
68
+ soft, _ = softmax_scores.max(dim=-1)
69
+
70
+ predictions: list[NerTuple] = []
71
+ for i in (predicted != 0).nonzero(as_tuple=True)[0].tolist():
72
+ label = self.vocab.get_token_from_index(int(predicted[i]), ns)
73
+ start, end = spans[i]
74
+ predictions.append((int(start), int(end), label, float(raw[i]), float(soft[i])))
75
+ return predictions
@@ -0,0 +1,27 @@
1
+ """Mention pruner — faithful port of ``training_v2/src/dygie/pruner.py``.
2
+
3
+ Score every span, keep the top-k in original span order. The ``scorer`` submodule name is preserved
4
+ for state-dict compatibility. batch_size=1 path: no masking, no batch dimension.
5
+ """
6
+
7
+ from __future__ import annotations
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(
19
+ self, span_embeddings: torch.Tensor, num_items_to_keep: int
20
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
21
+ scores = self.scorer(span_embeddings).squeeze(-1) # (num_spans,)
22
+ k = max(1, min(num_items_to_keep, span_embeddings.size(0)))
23
+ _, top_indices = scores.topk(k)
24
+ top_indices, _ = torch.sort(top_indices)
25
+ top_scores = scores[top_indices]
26
+ top_embeddings = span_embeddings[top_indices]
27
+ return top_embeddings, top_indices, top_scores