RadGraph-IT 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- radgraph_it-1.0.0.dist-info/METADATA +159 -0
- radgraph_it-1.0.0.dist-info/RECORD +39 -0
- radgraph_it-1.0.0.dist-info/WHEEL +4 -0
- radgraph_it-1.0.0.dist-info/entry_points.txt +2 -0
- radgraph_it-1.0.0.dist-info/licenses/LICENSE +23 -0
- radgraph_it-1.0.0.dist-info/licenses/LICENSES/Third-Party.md +41 -0
- radgraphit/__init__.py +47 -0
- radgraphit/api.py +168 -0
- radgraphit/artifacts/__init__.py +5 -0
- radgraphit/artifacts/manifest.py +290 -0
- radgraphit/artifacts/registry.py +24 -0
- radgraphit/artifacts/resolver.py +200 -0
- radgraphit/cli.py +131 -0
- radgraphit/core/__init__.py +1 -0
- radgraphit/core/errors.py +46 -0
- radgraphit/core/models.py +116 -0
- radgraphit/inference/__init__.py +5 -0
- radgraphit/inference/backends/__init__.py +1 -0
- radgraphit/inference/backends/base.py +13 -0
- radgraphit/inference/backends/dygie_v2/__init__.py +11 -0
- radgraphit/inference/backends/dygie_v2/backend.py +32 -0
- radgraphit/inference/backends/dygie_v2/device.py +46 -0
- radgraphit/inference/backends/dygie_v2/feedforward.py +25 -0
- radgraphit/inference/backends/dygie_v2/loader.py +165 -0
- radgraphit/inference/backends/dygie_v2/model.py +101 -0
- radgraphit/inference/backends/dygie_v2/ner_head.py +75 -0
- radgraphit/inference/backends/dygie_v2/pruner.py +27 -0
- radgraphit/inference/backends/dygie_v2/relation_head.py +147 -0
- radgraphit/inference/backends/dygie_v2/span_extractor.py +51 -0
- radgraphit/inference/backends/dygie_v2/spans.py +20 -0
- radgraphit/inference/backends/dygie_v2/tokenizer_embedder.py +123 -0
- radgraphit/inference/backends/dygie_v2/transformer_block.py +62 -0
- radgraphit/inference/backends/dygie_v2/vocab.py +123 -0
- radgraphit/inference/decoder.py +53 -0
- radgraphit/inference/service.py +80 -0
- radgraphit/inference/tokenizer.py +60 -0
- radgraphit/py.typed +0 -0
- radgraphit/serialization/__init__.py +15 -0
- radgraphit/serialization/radgraph_xl.py +135 -0
|
@@ -0,0 +1,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 @@
|
|
|
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
|