Radgraph-IT / document.py
roccoangelella's picture
Add transformers-compatible wrapper (AutoModel/AutoTokenizer via trust_remote_code)
0c48771 verified
Raw
History Blame Contribute Delete
9.47 kB
"""In-memory representation of a DyGIE-format document (JSONL).
Trimmed port of the vendored radgraph.dygie.data.dataset_readers.document module: only the
NER + relation pieces are kept (no coreference clusters, no events -- this project's configs
always set loss_weights.coref = loss_weights.events = 0, so those heads never ran in v1
either). See training/README.md for the schema.
"""
import re
import json
from typing import Any, Dict, List, Optional
import numpy as np
def _fields_to_batches(d: dict, keys_to_ignore=()):
"""Inverse of `_batches_to_fields`: {"a": [1, 2], "b": [3, 4]} -> [{"a": 1, "b": 3}, {"a": 2, "b": 4}]."""
keys = [k for k in d.keys() if k not in keys_to_ignore]
lengths = {k: len(d[k]) for k in keys}
if len(set(lengths.values())) != 1:
raise ValueError(f"For document {d.get('doc_key')}, fields have different lengths: {lengths}.")
length = next(iter(lengths.values()))
return [{k: d[k][i] for k in keys} for i in range(length)]
def _batches_to_fields(batches: List[dict]):
first_keys = batches[0].keys()
for entry in batches[1:]:
if set(entry.keys()) != set(first_keys):
raise ValueError("Keys do not match on all entries.")
res = {k: [] for k in first_keys}
for batch in batches:
for k, v in batch.items():
res[k].append(v)
return res
class Span:
"""A span, tracked both sentence-relative and document-relative."""
def __init__(self, start: int, end: int, sentence: "Sentence", sentence_offsets: bool = False):
self.sentence = sentence
# `sentence.text_joined` is memoized on Sentence (computed once) rather than rejoined
# here per span: the relation head can construct O(K^2) PredictedRelation/Span objects
# per document during decode (K = pruned span count), so re-joining a ~1000-word
# sentence per span turns into the dominant runtime cost otherwise.
self.sentence_text = sentence.text_joined
self.start_sent = start if sentence_offsets else start - sentence.sentence_start
self.end_sent = end if sentence_offsets else end - sentence.sentence_start
@property
def start_doc(self):
return self.start_sent + self.sentence.sentence_start
@property
def end_doc(self):
return self.end_sent + self.sentence.sentence_start
@property
def span_doc(self):
return (self.start_doc, self.end_doc)
@property
def span_sent(self):
return (self.start_sent, self.end_sent)
def __repr__(self):
return str(self.span_sent)
def __eq__(self, other):
return (self.span_doc == other.span_doc and self.span_sent == other.span_sent
and self.sentence == other.sentence)
def __hash__(self):
return hash(self.span_sent + (self.sentence_text,))
class NER:
def __init__(self, ner, sentence: "Sentence", sentence_offsets: bool = False):
self.span = Span(ner[0], ner[1], sentence, sentence_offsets)
self.label = ner[2]
def __repr__(self):
return f"{self.span!r}: {self.label}"
def __eq__(self, other):
return self.span == other.span and self.label == other.label
def to_json(self):
return list(self.span.span_doc) + [self.label]
def _format_float(x):
return round(x, 4)
class PredictedNER(NER):
def __init__(self, ner, sentence, sentence_offsets: bool = False):
"""`ner` = [span_start, span_end, label, raw_score, softmax_score]."""
super().__init__(ner, sentence, sentence_offsets)
self.raw_score = ner[3]
self.softmax_score = ner[4]
def to_json(self):
return super().to_json() + [_format_float(self.raw_score), _format_float(self.softmax_score)]
class Relation:
def __init__(self, relation, sentence: "Sentence", sentence_offsets: bool = False):
start1, end1, start2, end2, label = relation
self.pair = (Span(start1, end1, sentence, sentence_offsets),
Span(start2, end2, sentence, sentence_offsets))
self.label = label
def __repr__(self):
return f"{self.pair[0]!r}, {self.pair[1]!r}: {self.label}"
def __eq__(self, other):
return self.pair == other.pair and self.label == other.label
def to_json(self):
return list(self.pair[0].span_doc) + list(self.pair[1].span_doc) + [self.label]
class PredictedRelation(Relation):
def __init__(self, relation, sentence, sentence_offsets: bool = False):
"""`relation` = [start1, end1, start2, end2, label, raw_score, softmax_score]."""
super().__init__(relation[:5], sentence, sentence_offsets)
self.raw_score = relation[5]
self.softmax_score = relation[6]
def to_json(self):
return super().to_json() + [_format_float(self.raw_score), _format_float(self.softmax_score)]
class Sentence:
"""Despite the name, this project's documents always have exactly one "sentence" spanning
the whole report (see training/README.md); the multi-sentence machinery is kept because
the JSONL format is naturally list-of-sentences and nothing is gained by special-casing it.
"""
def __init__(self, entry: dict, sentence_start: int, sentence_ix: int):
self.sentence_start = sentence_start
self.sentence_ix = sentence_ix
self.text = entry["sentences"]
self.text_joined = " ".join(self.text) # memoized once; see Span.__init__
self.metadata = {k: v for k, v in entry.items() if k.startswith("_")}
if "ner" in entry:
self.ner = [NER(x, self) for x in entry["ner"]]
self.ner_dict = {e.span.span_sent: e.label for e in self.ner}
else:
self.ner, self.ner_dict = None, None
self.predicted_ner = ([PredictedNER(x, self) for x in entry["predicted_ner"]]
if "predicted_ner" in entry else None)
if "relations" in entry:
self.relations = [Relation(x, self) for x in entry["relations"]]
self.relation_dict = {(r.pair[0].span_sent, r.pair[1].span_sent): r.label
for r in self.relations}
else:
self.relations, self.relation_dict = None, None
self.predicted_relations = ([PredictedRelation(x, self) for x in entry["predicted_relations"]]
if "predicted_relations" in entry else None)
def to_json(self):
res = {"sentences": self.text}
if self.ner is not None:
res["ner"] = [e.to_json() for e in self.ner]
if self.predicted_ner is not None:
res["predicted_ner"] = [e.to_json() for e in self.predicted_ner]
if self.relations is not None:
res["relations"] = [r.to_json() for r in self.relations]
if self.predicted_relations is not None:
res["predicted_relations"] = [r.to_json() for r in self.predicted_relations]
res.update(self.metadata)
return res
def __len__(self):
return len(self.text)
def __repr__(self):
return " ".join(self.text)
class Document:
_ALLOWED_FIELDS = re.compile(r"doc_key|dataset|sentences|weight|.*ner$|.*relations$|^_.*")
def __init__(self, doc_key, dataset, sentences: List[Sentence], weight: Optional[float] = None):
self.doc_key = doc_key
self.dataset = dataset
self.sentences = sentences
self.weight = weight
@classmethod
def from_json(cls, js: Dict[str, Any]) -> "Document":
unexpected = [f for f in js if not cls._ALLOWED_FIELDS.match(f)]
if unexpected:
raise ValueError(f"Unexpected fields (prefix with '_' if intentional): {unexpected}")
doc_key = js["doc_key"]
dataset = js.get("dataset")
entries = _fields_to_batches(js, ("doc_key", "dataset", "weight"))
sentence_lengths = [len(e["sentences"]) for e in entries]
sentence_starts = np.roll(np.cumsum(sentence_lengths), 1)
sentence_starts[0] = 0
sentences = [Sentence(entry, int(start), ix)
for ix, (entry, start) in enumerate(zip(entries, sentence_starts.tolist()))]
return cls(doc_key, dataset, sentences, js.get("weight"))
def to_json(self):
res = {"doc_key": self.doc_key, "dataset": self.dataset}
res.update(_batches_to_fields([s.to_json() for s in self.sentences]))
if self.weight is not None:
res["weight"] = self.weight
return res
@property
def n_tokens(self):
return sum(len(s) for s in self.sentences)
def __getitem__(self, ix):
return self.sentences[ix]
def __len__(self):
return len(self.sentences)
def __repr__(self):
return "\n".join(f"{i}: {' '.join(s.text)}" for i, s in enumerate(self.sentences))
class Dataset:
def __init__(self, documents: List[Document]):
self.documents = documents
def __getitem__(self, i):
return self.documents[i]
def __len__(self):
return len(self.documents)
@classmethod
def from_jsonl(cls, fname):
documents = []
with open(fname) as f:
for line in f:
documents.append(Document.from_json(json.loads(line)))
return cls(documents)
def to_jsonl(self, fname):
with open(fname, "w") as f:
for doc in self.documents:
print(json.dumps(doc.to_json()), file=f)