From e0c45c669a0866cb20da4410a398eb71b46b0009 Mon Sep 17 00:00:00 2001 From: Sofie Van Landeghem Date: Wed, 3 Mar 2021 13:50:14 +0100 Subject: [PATCH 001/188] Native coref component (#7243) * initial coref_er pipe * matcher more flexible * base coref component without actual model * initial setup of coref_er.score * rename to include_label * preliminary score_clusters method * apply scoring in coref component * IO fix * return None loss for now * rename to CoreferenceResolver * some preliminary unit tests * use registry as callable --- spacy/ml/models/__init__.py | 1 + spacy/ml/models/coref.py | 18 ++ spacy/pipeline/__init__.py | 2 + spacy/pipeline/coref.py | 288 +++++++++++++++++++++++++++++ spacy/pipeline/coref_er.py | 227 +++++++++++++++++++++++ spacy/pipeline/entityruler.py | 3 +- spacy/pipeline/textcat.py | 2 +- spacy/scorer.py | 117 +++++++++++- spacy/tests/pipeline/test_coref.py | 180 ++++++++++++++++++ spacy/tokens/_dict_proxies.py | 2 +- 10 files changed, 829 insertions(+), 11 deletions(-) create mode 100644 spacy/ml/models/coref.py create mode 100644 spacy/pipeline/coref.py create mode 100644 spacy/pipeline/coref_er.py create mode 100644 spacy/tests/pipeline/test_coref.py diff --git a/spacy/ml/models/__init__.py b/spacy/ml/models/__init__.py index f032370193f..9b3a5c8bc80 100644 --- a/spacy/ml/models/__init__.py +++ b/spacy/ml/models/__init__.py @@ -1,3 +1,4 @@ +from .coref import * from .entity_linker import * # noqa from .multi_task import * # noqa from .parser import * # noqa diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py new file mode 100644 index 00000000000..68ce51bbb7a --- /dev/null +++ b/spacy/ml/models/coref.py @@ -0,0 +1,18 @@ +from typing import List +from thinc.api import Model +from thinc.types import Floats2d + +from ...util import registry +from ...tokens import Doc + + +@registry.architectures("spacy.Coref.v0") +def build_coref_model( + tok2vec: Model[List[Doc], List[Floats2d]] +) -> Model: + """Build a coref resolution model, using a provided token-to-vector component. + TODO. + + tok2vec (Model[List[Doc], List[Floats2d]]): The token-to-vector subnetwork. + """ + return tok2vec diff --git a/spacy/pipeline/__init__.py b/spacy/pipeline/__init__.py index 1fa53a55671..0eecff08f3e 100644 --- a/spacy/pipeline/__init__.py +++ b/spacy/pipeline/__init__.py @@ -1,4 +1,6 @@ from .attributeruler import AttributeRuler +from .coref import CoreferenceResolver +from .coref_er import CorefEntityRecognizer from .dep_parser import DependencyParser from .entity_linker import EntityLinker from .ner import EntityRecognizer diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py new file mode 100644 index 00000000000..9ccc2c89f0a --- /dev/null +++ b/spacy/pipeline/coref.py @@ -0,0 +1,288 @@ +from typing import Iterable, Tuple, Optional, Dict, Callable, Any + +from thinc.api import get_array_module, Model, Optimizer, set_dropout_rate, Config +from itertools import islice + +from .trainable_pipe import TrainablePipe +from .coref_er import DEFAULT_MENTIONS +from ..language import Language +from ..training import Example, validate_examples, validate_get_examples +from ..errors import Errors +from ..scorer import Scorer +from ..tokens import Doc +from ..vocab import Vocab + + +default_config = """ +[model] +@architectures = "spacy.Coref.v0" + +[model.tok2vec] +@architectures = "spacy.Tok2Vec.v2" + +[model.tok2vec.embed] +@architectures = "spacy.MultiHashEmbed.v1" +width = 64 +rows = [2000, 2000, 1000, 1000, 1000, 1000] +attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] +include_static_vectors = false + +[model.tok2vec.encode] +@architectures = "spacy.MaxoutWindowEncoder.v2" +width = ${model.tok2vec.embed.width} +window_size = 1 +maxout_pieces = 3 +depth = 2 +""" +DEFAULT_MODEL = Config().from_str(default_config)["model"] + +DEFAULT_CLUSTERS_PREFIX = "coref_clusters" + + +@Language.factory( + "coref", + assigns=[f"doc.spans"], + requires=["doc.spans"], + default_config={ + "model": DEFAULT_MODEL, + "span_mentions": DEFAULT_MENTIONS, + "span_cluster_prefix": DEFAULT_CLUSTERS_PREFIX, + }, + default_score_weights={"coref_f": 1.0, "coref_p": None, "coref_r": None}, +) +def make_coref( + nlp: Language, + name: str, + model, + span_mentions: str, + span_cluster_prefix: str, +) -> "CoreferenceResolver": + """Create a CoreferenceResolver component. TODO + + model (Model[List[Doc], List[Floats2d]]): A model instance that predicts ... + threshold (float): Cutoff to consider a prediction "positive". + """ + return CoreferenceResolver( + nlp.vocab, + model, + name, + span_mentions=span_mentions, + span_cluster_prefix=span_cluster_prefix, + ) + + +class CoreferenceResolver(TrainablePipe): + """Pipeline component for coreference resolution. + + DOCS: https://spacy.io/api/coref (TODO) + """ + + def __init__( + self, + vocab: Vocab, + model: Model, + name: str = "coref", + *, + span_mentions: str, + span_cluster_prefix: str, + ) -> None: + """Initialize a coreference resolution component. + + vocab (Vocab): The shared vocabulary. + model (thinc.api.Model): The Thinc Model powering the pipeline component. + name (str): The component instance name, used to add entries to the + losses during training. + span_mentions (str): Key in doc.spans where the candidate coref mentions + are stored in. + span_cluster_prefix (str): Prefix for the key in doc.spans to store the + coref clusters in. + + DOCS: https://spacy.io/api/coref#init (TODO) + """ + self.vocab = vocab + self.model = model + self.name = name + self.span_mentions = span_mentions + self.span_cluster_prefix = span_cluster_prefix + self._rehearsal_model = None + self.cfg = {} + + def predict(self, docs: Iterable[Doc]): + """Apply the pipeline's model to a batch of docs, without modifying them. + TODO: write actual algorithm + + docs (Iterable[Doc]): The documents to predict. + RETURNS: The models prediction for each document. + + DOCS: https://spacy.io/api/coref#predict (TODO) + """ + clusters_by_doc = [] + for i, doc in enumerate(docs): + clusters = [] + for span in doc.spans[self.span_mentions]: + clusters.append([span]) + clusters_by_doc.append(clusters) + return clusters_by_doc + + def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: + """Modify a batch of Doc objects, using pre-computed scores. + + docs (Iterable[Doc]): The documents to modify. + clusters: The span clusters, produced by CoreferenceResolver.predict. + + DOCS: https://spacy.io/api/coref#set_annotations (TODO) + """ + if len(docs) != len(clusters_by_doc): + raise ValueError("Found coref clusters incompatible with the " + "documents provided to the 'coref' component. " + "This is likely a bug in spaCy.") + for doc, clusters in zip(docs, clusters_by_doc): + index = 0 + for cluster in clusters: + key = self.span_cluster_prefix + str(index) + if key in doc.spans: + raise ValueError(f"Couldn't store the results of {self.name}, as the key " + f"{key} already exists in 'doc.spans'.") + doc.spans[key] = cluster + index += 1 + + def update( + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, + losses: Optional[Dict[str, float]] = None, + ) -> Dict[str, float]: + """Learn from a batch of documents and gold-standard information, + updating the pipe's model. Delegates to predict and get_loss. + + examples (Iterable[Example]): A batch of Example objects. + drop (float): The dropout rate. + sgd (thinc.api.Optimizer): The optimizer. + losses (Dict[str, float]): Optional record of the loss during training. + Updated using the component name as the key. + RETURNS (Dict[str, float]): The updated losses dictionary. + + DOCS: https://spacy.io/api/coref#update (TODO) + """ + if losses is None: + losses = {} + losses.setdefault(self.name, 0.0) + validate_examples(examples, "CoreferenceResolver.update") + if not any(len(eg.predicted) if eg.predicted else 0 for eg in examples): + # Handle cases where there are no tokens in any docs. + return losses + set_dropout_rate(self.model, drop) + scores, bp_scores = self.model.begin_update([eg.predicted for eg in examples]) + # TODO below + # loss, d_scores = self.get_loss(examples, scores) + # bp_scores(d_scores) + if sgd is not None: + self.finish_update(sgd) + # losses[self.name] += loss + return losses + + def rehearse( + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, + losses: Optional[Dict[str, float]] = None, + ) -> Dict[str, float]: + """Perform a "rehearsal" update from a batch of data. Rehearsal updates + teach the current model to make predictions similar to an initial model, + to try to address the "catastrophic forgetting" problem. This feature is + experimental. + + examples (Iterable[Example]): A batch of Example objects. + drop (float): The dropout rate. + sgd (thinc.api.Optimizer): The optimizer. + losses (Dict[str, float]): Optional record of the loss during training. + Updated using the component name as the key. + RETURNS (Dict[str, float]): The updated losses dictionary. + + DOCS: https://spacy.io/api/coref#rehearse (TODO) + """ + if losses is not None: + losses.setdefault(self.name, 0.0) + if self._rehearsal_model is None: + return losses + validate_examples(examples, "CoreferenceResolver.rehearse") + docs = [eg.predicted for eg in examples] + if not any(len(doc) for doc in docs): + # Handle cases where there are no tokens in any docs. + return losses + set_dropout_rate(self.model, drop) + scores, bp_scores = self.model.begin_update(docs) + # TODO below + target = self._rehearsal_model(examples) + gradient = scores - target + bp_scores(gradient) + if sgd is not None: + self.finish_update(sgd) + if losses is not None: + losses[self.name] += (gradient ** 2).sum() + return losses + + def add_label(self, label: str) -> int: + """Technically this method should be implemented from TrainablePipe, + but it is not relevant for the coref component. + """ + raise NotImplementedError( + Errors.E931.format( + parent="CoreferenceResolver", method="add_label", name=self.name + ) + ) + + def get_loss(self, examples: Iterable[Example], scores) -> Tuple[float, float]: + """Find the loss and gradient of loss for the batch of documents and + their predicted scores. + + examples (Iterable[Examples]): The batch of examples. + scores: Scores representing the model's predictions. + RETURNS (Tuple[float, float]): The loss and the gradient. + + DOCS: https://spacy.io/api/coref#get_loss (TODO) + """ + validate_examples(examples, "CoreferenceResolver.get_loss") + # TODO + return None + + def initialize( + self, + get_examples: Callable[[], Iterable[Example]], + *, + nlp: Optional[Language] = None, + ) -> None: + """Initialize the pipe for training, using a representative set + of data examples. + + get_examples (Callable[[], Iterable[Example]]): Function that + returns a representative sample of gold-standard Example objects. + nlp (Language): The current nlp object the component is part of. + + DOCS: https://spacy.io/api/coref#initialize (TODO) + """ + validate_get_examples(get_examples, "CoreferenceResolver.initialize") + subbatch = list(islice(get_examples(), 10)) + doc_sample = [eg.reference for eg in subbatch] + assert len(doc_sample) > 0, Errors.E923.format(name=self.name) + self.model.initialize(X=doc_sample) + + def score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: + """Score a batch of examples. + + examples (Iterable[Example]): The examples to score. + RETURNS (Dict[str, Any]): The scores, produced by Scorer.score_coref. + + DOCS: https://spacy.io/api/coref#score (TODO) + """ + def clusters_getter(doc, span_key): + return [spans for name, spans in doc.spans.items() if name.startswith(span_key)] + validate_examples(examples, "CoreferenceResolver.score") + kwargs.setdefault("getter", clusters_getter) + kwargs.setdefault("attr", self.span_cluster_prefix) + kwargs.setdefault("include_label", False) + return Scorer.score_clusters(examples, **kwargs) diff --git a/spacy/pipeline/coref_er.py b/spacy/pipeline/coref_er.py new file mode 100644 index 00000000000..585bdafddb4 --- /dev/null +++ b/spacy/pipeline/coref_er.py @@ -0,0 +1,227 @@ +from typing import Optional, Union, Iterable, Callable, List, Dict, Any +from pathlib import Path +import srsly + +from .pipe import Pipe +from ..scorer import Scorer +from ..training import Example +from ..language import Language +from ..tokens import Doc, Span, SpanGroup +from ..matcher import Matcher +from ..util import ensure_path, to_disk, from_disk, SimpleFrozenList + + +DEFAULT_MENTIONS = "coref_mentions" +DEFAULT_MATCHER_KEY = "POS" +DEFAULT_MATCHER_VALUES = ["PROPN", "PRON"] + + +@Language.factory( + "coref_er", + assigns=[f"doc.spans"], + requires=["doc.ents", "token.ent_iob", "token.ent_type", "token.pos"], + default_config={ + "span_mentions": DEFAULT_MENTIONS, + "matcher_key": DEFAULT_MATCHER_KEY, + "matcher_values": DEFAULT_MATCHER_VALUES, + }, + default_score_weights={ + "coref_mentions_f": None, + "coref_mentions_p": None, + "coref_mentions_r": 1.0, # the mentions data needs to be consistently annotated for precision rates to make sense + }, +) +def make_coref_er(nlp: Language, name: str, span_mentions: str, matcher_key: str, matcher_values: List[str]): + return CorefEntityRecognizer( + nlp, name, span_mentions=span_mentions, matcher_key=matcher_key, matcher_values=matcher_values + ) + + +class CorefEntityRecognizer(Pipe): + """TODO. + + DOCS: https://spacy.io/api/coref_er (TODO) + USAGE: https://spacy.io/usage (TODO) + """ + + def __init__( + self, + nlp: Language, + name: str = "coref_er", + *, + span_mentions: str, + matcher_key: str, + matcher_values: List[str], + ) -> None: + """Initialize the entity recognizer for coreference mentions. TODO + + nlp (Language): The shared nlp object. + name (str): Instance name of the current pipeline component. Typically + passed in automatically from the factory when the component is + added. + span_mentions (str): Key in doc.spans to store the coref mentions in. + matcher_key (List[str]): Field for the matcher to work on (e.g. "POS" or "TAG") + matcher_values (List[str]): Values to match token sequences as + plausible coref mentions + + DOCS: https://spacy.io/api/coref_er#init (TODO) + """ + self.nlp = nlp + self.name = name + self.span_mentions = span_mentions + self.matcher_key = matcher_key + self.matcher_values = matcher_values + self.matcher = Matcher(nlp.vocab) + # TODO: allow to specify any matcher patterns instead? + for value in matcher_values: + self.matcher.add( + f"{value}_SEQ", [[{matcher_key: value, "OP": "+"}]], greedy="LONGEST" + ) + + @staticmethod + def _string_offset(span: Span): + return f"{span.start}-{span.end}" + + def __call__(self, doc: Doc) -> Doc: + """Find relevant coref mentions in the document and add them + to the doc's relevant span container. + + doc (Doc): The Doc object in the pipeline. + RETURNS (Doc): The Doc with added entities, if available. + + DOCS: https://spacy.io/api/coref_er#call (TODO) + """ + error_handler = self.get_error_handler() + try: + # Add NER + spans = list(doc.ents) + offsets = set() + offsets.update([self._string_offset(e) for e in doc.ents]) + + # pronouns and proper nouns + try: + matches = self.matcher(doc, as_spans=True) + except ValueError: + raise ValueError(f"Could not run the matcher for 'coref_er'. If {self.matcher_key} tags " + "are not available, change the 'matcher_key' in the config, " + "or set matcher_values to an empty list.") + spans.extend([m for m in matches if self._string_offset(m) not in offsets]) + offsets.update([self._string_offset(m) for m in matches]) + + # noun_chunks - only if implemented and parsing information is available + try: + spans.extend( + [nc for nc in doc.noun_chunks if self._string_offset(nc) not in offsets] + ) + offsets.update([self._string_offset(nc) for nc in doc.noun_chunks]) + except (NotImplementedError, ValueError): + pass + + self.set_annotations(doc, spans) + return doc + except Exception as e: + error_handler(self.name, self, [doc], e) + + def set_annotations(self, doc, spans): + """Modify the document in place""" + group = SpanGroup(doc, name=self.span_mentions, spans=spans) + if self.span_mentions in doc.spans: + raise ValueError(f"Couldn't store the results of {self.name}, as the key " + f"{self.span_mentions} already exists in 'doc.spans'.") + doc.spans[self.span_mentions] = group + + def initialize( + self, + get_examples: Callable[[], Iterable[Example]], + *, + nlp: Optional[Language] = None, + ): + """Initialize the pipe for training. + + get_examples (Callable[[], Iterable[Example]]): Function that + returns a representative sample of gold-standard Example objects. + nlp (Language): The current nlp object the component is part of. + + DOCS: https://spacy.io/api/coref_er#initialize (TODO) + """ + pass + + def from_bytes( + self, bytes_data: bytes, *, exclude: Iterable[str] = SimpleFrozenList() + ) -> "CorefEntityRecognizer": + """Load the coreference entity recognizer from a bytestring. + + bytes_data (bytes): The bytestring to load. + RETURNS (CorefEntityRecognizer): The loaded coreference entity recognizer. + + DOCS: https://spacy.io/api/coref_er#from_bytes + """ + cfg = srsly.msgpack_loads(bytes_data) + self.span_mentions = cfg.get("span_mentions", DEFAULT_MENTIONS) + self.matcher_key = cfg.get("matcher_key", DEFAULT_MATCHER_KEY) + self.matcher_values = cfg.get("matcher_values", DEFAULT_MATCHER_VALUES) + return self + + def to_bytes(self, *, exclude: Iterable[str] = SimpleFrozenList()) -> bytes: + """Serialize the coreference entity recognizer to a bytestring. + + RETURNS (bytes): The serialized component. + + DOCS: https://spacy.io/api/coref_er#to_bytes (TODO) + """ + serial = {"span_mentions": self.span_mentions} + return srsly.msgpack_dumps(serial) + + def from_disk( + self, path: Union[str, Path], *, exclude: Iterable[str] = SimpleFrozenList() + ) -> "CorefEntityRecognizer": + """Load the coreference entity recognizer from a file. + + path (str / Path): The JSONL file to load. + RETURNS (CorefEntityRecognizer): The loaded coreference entity recognizer . + + DOCS: https://spacy.io/api/coref_er#from_disk (TODO) + """ + path = ensure_path(path) + cfg = {} + deserializers_cfg = {"cfg": lambda p: cfg.update(srsly.read_json(p))} + from_disk(path, deserializers_cfg, {}) + self.span_mentions = cfg.get("span_mentions", DEFAULT_MENTIONS) + self.matcher_key = cfg.get("matcher_key", DEFAULT_MATCHER_KEY) + self.matcher_values = cfg.get("matcher_values", DEFAULT_MATCHER_VALUES) + return self + + def to_disk( + self, path: Union[str, Path], *, exclude: Iterable[str] = SimpleFrozenList() + ) -> None: + """Save the coreference entity recognizer to a directory. + + path (str / Path): The JSONL file to save. + + DOCS: https://spacy.io/api/coref_er#to_disk (TODO) + """ + path = ensure_path(path) + cfg = { + "span_mentions": self.span_mentions, + "matcher_key": self.matcher_key, + "matcher_values": self.matcher_values, + } + serializers = {"cfg": lambda p: srsly.write_json(p, cfg)} + to_disk(path, serializers, {}) + + def score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: + """Score a batch of examples. + + examples (Iterable[Example]): The examples to score. + RETURNS (Dict[str, Any]): The scores, produced by Scorer.score_coref. + + DOCS: https://spacy.io/api/coref_er#score (TODO) + """ + def mentions_getter(doc, span_key): + return doc.spans[span_key] + # This will work better once PR 7209 is merged + kwargs.setdefault("getter", mentions_getter) + kwargs.setdefault("attr", self.span_mentions) + kwargs.setdefault("include_label", False) + kwargs.setdefault("allow_overlap", True) + return Scorer.score_spans(examples, **kwargs) diff --git a/spacy/pipeline/entityruler.py b/spacy/pipeline/entityruler.py index 25bc3abeeb9..d6927dddc7b 100644 --- a/spacy/pipeline/entityruler.py +++ b/spacy/pipeline/entityruler.py @@ -56,8 +56,7 @@ class EntityRuler(Pipe): """The EntityRuler lets you add spans to the `Doc.ents` using token-based rules or exact phrase matches. It can be combined with the statistical `EntityRecognizer` to boost accuracy, or used on its own to implement a - purely rule-based entity recognition system. After initialization, the - component is typically added to the pipeline using `nlp.add_pipe`. + purely rule-based entity recognition system. DOCS: https://spacy.io/api/entityruler USAGE: https://spacy.io/usage/rule-based-matching#entityruler diff --git a/spacy/pipeline/textcat.py b/spacy/pipeline/textcat.py index f94bde84f3e..b8b32f53498 100644 --- a/spacy/pipeline/textcat.py +++ b/spacy/pipeline/textcat.py @@ -1,8 +1,8 @@ -from itertools import islice from typing import Iterable, Tuple, Optional, Dict, List, Callable, Any from thinc.api import get_array_module, Model, Optimizer, set_dropout_rate, Config from thinc.types import Floats2d import numpy +from itertools import islice from .trainable_pipe import TrainablePipe from ..language import Language diff --git a/spacy/scorer.py b/spacy/scorer.py index f28cb5639c1..969f6529387 100644 --- a/spacy/scorer.py +++ b/spacy/scorer.py @@ -341,7 +341,7 @@ def score_spans( for label in labels: if label not in score_per_type: score_per_type[label] = PRFScore() - # Find all predidate labels, for all and per type + # Find all instances, for all and per type gold_spans = set() pred_spans = set() for span in getter(gold_doc, attr): @@ -373,6 +373,114 @@ def score_spans( f"{attr}_per_type": None, } + @staticmethod + def score_clusters( + examples: Iterable[Example], + attr: str, + *, + getter: Callable[[Doc, str], Iterable[Iterable[Span]]] = getattr, + has_annotation: Optional[Callable[[Doc], bool]] = None, + include_label: bool = True, + **cfg, + ) -> Dict[str, Any]: + """Returns PRF scores for clustered spans. + + examples (Iterable[Example]): Examples to score + attr (str): The attribute to score. + getter (Callable[[Doc, str], Iterable[Iterable[Span]]]): Defaults to getattr. + If provided, getter(doc, attr) should return the lists of spans for the + individual doc. + has_annotation (Optional[Callable[[Doc], bool]]) should return whether a `Doc` + has annotation for this `attr`. Docs without annotation are skipped for + scoring purposes. + include_label (bool): Whether or not to include label information in + the evaluation. If set to 'False', two spans will be considered + equal if their start and end match, irrespective of their label. + + RETURNS (Dict[str, Any]): A dictionary containing the PRF scores under + the keys attr_p/r/f and the per-type PRF scores under attr_per_type. + + DOCS: https://spacy.io/api/scorer#score_clusters (TODO) + """ + # Note: the current implementation just scores binary pairs on whether they + # are in the same cluster or not. + # TODO: look at different cluster/coreference scoring techniques. + score = PRFScore() + score_per_type = dict() + for example in examples: + pred_doc = example.predicted + gold_doc = example.reference + # Option to handle docs without sents + if has_annotation is not None: + if not has_annotation(gold_doc): + continue + # Find all labels in gold and doc + gold_clusters = list(getter(gold_doc, attr)) + pred_clusters = list(getter(pred_doc, attr)) + labels = set( + [span.label_ for span_list in gold_clusters for span in span_list] + + [span.label_ for span_list in pred_clusters for span in span_list] + ) + # Set up all labels for per type scoring and prepare gold per type + for label in labels: + if label not in score_per_type: + score_per_type[label] = PRFScore() + # Find all instances, for all and per type + gold_instances = set() + gold_per_type = {label: set() for label in labels} + for gold_cluster in gold_clusters: + for span1 in gold_cluster: + for span2 in gold_cluster: + # only record pairs where span1 comes before span2 + if (span1.start < span2.start) or (span1.start == span2.start and span1.end < span2.end): + if include_label: + gold_rel = (span1.label_, span1.start, span1.end - 1, span2.label_, span2.start, span2.end - 1) + else: + gold_rel = (span1.start, span1.end - 1, span2.start, span2.end - 1) + gold_instances.add(gold_rel) + if span1.label_ == span2.label_: + gold_per_type[span1.label_].add(gold_rel) + pred_instances = set() + pred_per_type = {label: set() for label in labels} + for pred_cluster in pred_clusters: + for span1 in pred_cluster: + for span2 in pred_cluster: + if (span1.start < span2.start) or (span1.start == span2.start and span1.end < span2.end): + if include_label: + pred_rel = (span1.label_, span1.start, span1.end - 1, span2.label_, span2.start, span2.end - 1) + else: + pred_rel = (span1.start, span1.end - 1, span2.start, span2.end - 1) + pred_instances.add(pred_rel) + if span1.label_ == span2.label_: + pred_per_type[span1.label_].add(pred_rel) + # Scores per label + if include_label: + for k, v in score_per_type.items(): + if k in pred_per_type: + v.score_set(pred_per_type[k], gold_per_type[k]) + # Score for all labels + score.score_set(pred_instances, gold_instances) + # Assemble final result + final_scores = { + f"{attr}_p": None, + f"{attr}_r": None, + f"{attr}_f": None, + + } + if include_label: + final_scores[f"{attr}_per_type"] = None + if len(score) > 0: + final_scores[f"{attr}_p"] = score.precision + final_scores[f"{attr}_r"] = score.recall + final_scores[f"{attr}_f"] = score.fscore + return { + f"{attr}_p": None, + f"{attr}_r": None, + f"{attr}_f": None, + f"{attr}_per_type": None, + } + return final_scores + @staticmethod def score_cats( examples: Iterable[Example], @@ -722,12 +830,7 @@ def get_ner_prf(examples: Iterable[Example]) -> Dict[str, Any]: "ents_per_type": {k: v.to_dict() for k, v in score_per_type.items()}, } else: - return { - "ents_p": None, - "ents_r": None, - "ents_f": None, - "ents_per_type": None, - } + return {"ents_p": None, "ents_r": None, "ents_f": None, "ents_per_type": None} # The following implementation of roc_auc_score() is adapted from diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py new file mode 100644 index 00000000000..ab3de704492 --- /dev/null +++ b/spacy/tests/pipeline/test_coref.py @@ -0,0 +1,180 @@ +import pytest +import spacy +from spacy.matcher import PhraseMatcher +from spacy.training import Example +from spacy.lang.en import English +from spacy.tests.util import make_tempdir +from spacy.tokens import Doc +from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX +from spacy.pipeline.coref_er import DEFAULT_MENTIONS + + +# fmt: off +TRAIN_DATA = [ + ( + "John Smith told Laura that he was running late and asked her whether she could pick up their kids.", + { + "spans": { + DEFAULT_MENTIONS: [ + (0, 10, "MENTION"), + (16, 21, "MENTION"), + (27, 29, "MENTION"), + (57, 60, "MENTION"), + (69, 72, "MENTION"), + (87, 92, "MENTION"), + (87, 97, "MENTION"), + ], + f"{DEFAULT_CLUSTERS_PREFIX}_1": [ + (0, 10, "MENTION"), # John + (27, 29, "MENTION"), + (87, 92, "MENTION"), # 'their' refers to John and Laur + ], + f"{DEFAULT_CLUSTERS_PREFIX}_2": [ + (16, 21, "MENTION"), # Laura + (57, 60, "MENTION"), + (69, 72, "MENTION"), + (87, 92, "MENTION"), # 'their' refers to John and Laura + ], + } + }, + ), + ( + "Yes, I noticed that many friends around me received it. It seems that almost everyone received this SMS.", + { + "spans": { + DEFAULT_MENTIONS: [ + (5, 6, "MENTION"), + (40, 42, "MENTION"), + (52, 54, "MENTION"), + (95, 103, "MENTION"), + ], + f"{DEFAULT_CLUSTERS_PREFIX}_1": [ + (5, 6, "MENTION"), # I + (40, 42, "MENTION"), + + ], + f"{DEFAULT_CLUSTERS_PREFIX}_2": [ + (52, 54, "MENTION"), # SMS + (95, 103, "MENTION"), + ] + } + }, + ), +] +# fmt: on + + +@pytest.fixture +def nlp(): + return English() + + +@pytest.fixture +def examples(nlp): + examples = [] + for text, annot in TRAIN_DATA: + # eg = Example.from_dict(nlp.make_doc(text), annot) + # if PR #7197 is merged, replace below with above line + ref_doc = nlp.make_doc(text) + for key, span_list in annot["spans"].items(): + spans = [] + for span_tuple in span_list: + start_char = span_tuple[0] + end_char = span_tuple[1] + label = span_tuple[2] + span = ref_doc.char_span(start_char, end_char, label=label) + spans.append(span) + ref_doc.spans[key] = spans + eg = Example(nlp.make_doc(text), ref_doc) + examples.append(eg) + return examples + + +def test_coref_er_no_POS(nlp): + doc = nlp("The police woman talked to him.") + coref_er = nlp.add_pipe("coref_er", last=True) + with pytest.raises(ValueError): + coref_er(doc) + + +def test_coref_er_with_POS(nlp): + words = ["The", "police", "woman", "talked", "to", "him", "."] + pos = ["DET", "NOUN", "NOUN", "VERB", "ADP", "PRON", "PUNCT"] + doc = Doc(nlp.vocab, words=words, pos=pos) + coref_er = nlp.add_pipe("coref_er", last=True) + coref_er(doc) + assert len(doc.spans[coref_er.span_mentions]) == 1 + mention = doc.spans[coref_er.span_mentions][0] + assert (mention.text, mention.start, mention.end) == ("him", 5, 6) + + +def test_coref_er_custom_POS(nlp): + words = ["The", "police", "woman", "talked", "to", "him", "."] + pos = ["DET", "NOUN", "NOUN", "VERB", "ADP", "PRON", "PUNCT"] + doc = Doc(nlp.vocab, words=words, pos=pos) + config = {"matcher_key": "POS", "matcher_values": ["NOUN"]} + coref_er = nlp.add_pipe("coref_er", last=True, config=config) + coref_er(doc) + assert len(doc.spans[coref_er.span_mentions]) == 1 + mention = doc.spans[coref_er.span_mentions][0] + assert (mention.text, mention.start, mention.end) == ("police woman", 1, 3) + + +def test_coref_clusters(nlp, examples): + coref_er = nlp.add_pipe("coref_er", last=True) + coref = nlp.add_pipe("coref", last=True) + coref.initialize(lambda: examples) + words = ["Laura", "walked", "her", "dog", "."] + pos = ["PROPN", "VERB", "PRON", "NOUN", "PUNCT"] + doc = Doc(nlp.vocab, words=words, pos=pos) + coref_er(doc) + coref(doc) + assert len(doc.spans[coref_er.span_mentions]) > 0 + found_clusters = 0 + for name, spans in doc.spans.items(): + if name.startswith(coref.span_cluster_prefix): + found_clusters += 1 + assert found_clusters > 0 + + +def test_coref_er_score(nlp, examples): + config = {"matcher_key": "POS", "matcher_values": []} + coref_er = nlp.add_pipe("coref_er", last=True, config=config) + coref = nlp.add_pipe("coref", last=True) + coref.initialize(lambda: examples) + mentions_key = coref_er.span_mentions + cluster_prefix_key = coref.span_cluster_prefix + matcher = PhraseMatcher(nlp.vocab) + terms_1 = ["Laura", "her", "she"] + terms_2 = ["it", "this SMS"] + matcher.add("A", [nlp.make_doc(text) for text in terms_1]) + matcher.add("B", [nlp.make_doc(text) for text in terms_2]) + for eg in examples: + pred = eg.predicted + matches = matcher(pred, as_spans=True) + pred.set_ents(matches) + coref_er(pred) + coref(pred) + eg.predicted = pred + # TODO: if #7209 is merged, experiment with 'include_label' + scores = coref_er.score([eg]) + assert f"{mentions_key}_f" in scores + scores = coref.score([eg]) + assert f"{cluster_prefix_key}_f" in scores + + +def test_coref_serialization(nlp): + # Test that the coref component can be serialized + config_er = {"matcher_key": "TAG", "matcher_values": ["NN"]} + nlp.add_pipe("coref_er", last=True, config=config_er) + nlp.add_pipe("coref", last=True) + assert "coref_er" in nlp.pipe_names + assert "coref" in nlp.pipe_names + + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = spacy.load(tmp_dir) + assert "coref_er" in nlp2.pipe_names + assert "coref" in nlp2.pipe_names + coref_er_2 = nlp2.get_pipe("coref_er") + assert coref_er_2.matcher_key == "TAG" diff --git a/spacy/tokens/_dict_proxies.py b/spacy/tokens/_dict_proxies.py index 9ee1ad02f70..6194cdeffec 100644 --- a/spacy/tokens/_dict_proxies.py +++ b/spacy/tokens/_dict_proxies.py @@ -13,7 +13,7 @@ # Why inherit from UserDict instead of dict here? # Well, the 'dict' class doesn't necessarily delegate everything nicely, -# for performance reasons. The UserDict is slower by better behaved. +# for performance reasons. The UserDict is slower but better behaved. # See https://treyhunner.com/2019/04/why-you-shouldnt-inherit-from-list-and-dict-in-python/0ww class SpanGroups(UserDict): """A dict-like proxy held by the Doc, to control access to span groups.""" From 7c42a8c90a552f996dcc2ae0221b247da1925342 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 15 May 2021 21:36:10 +0900 Subject: [PATCH 002/188] Migrate coref code This includes the coref code that was being tested separately, modified to work in spaCy. It hasn't been tested yet and presumably still needs fixes. In particular, the evaluation code is currently omitted. It's unclear at the moment whether we want to use a complex scorer similar to the official one, or a simpler scorer using more modern evaluation methods. --- spacy/ml/models/coref.py | 406 +++++++++++++++++++++++++++++++++- spacy/ml/models/coref_util.py | 252 +++++++++++++++++++++ spacy/pipeline/coref.py | 190 ++++++++++++---- 3 files changed, 793 insertions(+), 55 deletions(-) create mode 100644 spacy/ml/models/coref_util.py diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 68ce51bbb7a..ba522b1f24b 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,18 +1,402 @@ -from typing import List -from thinc.api import Model -from thinc.types import Floats2d +from dataclasses import dataclass -from ...util import registry +from thinc.api import Model, Linear, Relu, Dropout, chain, noop +from thinc.types import Floats2d, Floats1d, Ints2d, Ragged +from typing import List, Callable, Tuple from ...tokens import Doc +from ...util import registry + +from .coref_util import ( + get_predicted_clusters, + get_candidate_mentions, + select_non_crossing_spans, + make_clean_doc, + create_gold_scores, + logsumexp, + topk, +) @registry.architectures("spacy.Coref.v0") -def build_coref_model( - tok2vec: Model[List[Doc], List[Floats2d]] -) -> Model: - """Build a coref resolution model, using a provided token-to-vector component. - TODO. +def build_coref( + tok2vec: Model[List[Doc], List[Floats2d]], + get_mentions: Callable = get_candidate_mentions, + hidden: int = 1000, + dropout: float = 0.3, + mention_limit: int = 3900, + max_span_width: int = 20, +): + dim = tok2vec.get_dim("nO") * 3 + + span_embedder = build_span_embedder(get_mentions, max_span_width) + + with Model.define_operators({">>": chain, "&": tuplify}): + + mention_scorer = ( + Linear(nI=dim, nO=hidden) + >> Relu(nI=hidden, nO=hidden) + >> Dropout(dropout) + >> Linear(nI=hidden, nO=1) + ) + mention_scorer.initialize() + + bilinear = Linear(nI=dim, nO=dim) >> Dropout(dropout) + bilinear.initialize() + + ms = build_take_vecs() >> mention_scorer + + model = ( + (tok2vec & noop()) + >> span_embedder + >> (ms & noop()) + >> build_coarse_pruner(mention_limit) + >> build_ant_scorer(bilinear, Dropout(dropout)) + ) + return model + + +# TODO replace this with thinc version once PR is in +def tuplify(layer1: Model, layer2: Model, *layers) -> Model: + layers = (layer1, layer2) + layers + names = [layer.name for layer in layers] + return Model( + "tuple(" + ", ".join(names) + ")", + tuplify_forward, + layers=layers, + ) + + +def tuplify_forward(model, X, is_train): + Ys = [] + backprops = [] + for layer in model.layers: + Y, backprop = layer(X, is_train) + Ys.append(Y) + backprops.append(backprop) + + def backprop_tuplify(dYs): + dXs = [bp(dY) for bp, dY in zip(backprops, dYs)] + dX = dXs[0] + for dx in dXs[1:]: + dX += dx + return dX + + return tuple(Ys), backprop_tuplify + + +@dataclass +class SpanEmbeddings: + indices: Ints2d # Array with 2 columns (for start and end index) + vectors: Ragged # Ragged[Floats2d] # One vector per span + # NB: We assume that the indices refer to a concatenated Floats2d that + # has one row per token in the *batch* of documents. This makes it unambiguous + # which row is in which document, because if the lengths are e.g. [10, 5], + # a span starting at 11 must be starting at token 2 of doc 1. A bug could + # potentially cause you to have a span which crosses a doc boundary though, + # which would be bad. + # The lengths in the Ragged are not the tokens per doc, but the number of + # mentions per doc. + + def __add__(self, right): + out = self.vectors.data + right.vectors.data + return SpanEmbeddings(self.indices, Ragged(out, self.vectors.lengths)) + + def __iadd__(self, right): + self.vectors.data += right.vectors.data + return self + + +# model converting a Doc/Mention to span embeddings +# get_mentions: Callable[Doc, Pairs[int]] +def build_span_embedder( + get_mentions: Callable, + max_span_width: int = 20, +) -> Model[Tuple[List[Floats2d], List[Doc]], SpanEmbeddings]: + + return Model( + "SpanEmbedding", + forward=span_embeddings_forward, + attrs={ + "get_mentions": get_mentions, + # XXX might be better to make this an implicit parameter in the + # mention generator + "max_span_width": max_span_width, + }, + ) + + +def span_embeddings_forward( + model, inputs: Tuple[List[Floats2d], List[Doc]], is_train +) -> SpanEmbeddings: + ops = model.ops + xp = ops.xp + + tokvecs, docs = inputs + + dim = tokvecs[0].shape[1] + + get_mentions = model.attrs["get_mentions"] + max_span_width = model.attrs["max_span_width"] + mentions = ops.alloc2i(0, 2) + total_length = 0 + docmenlens = [] # number of mentions per doc + for doc in docs: + starts, ends = get_mentions(doc, max_span_width) + docmenlens.append(len(starts)) + cments = ops.asarray2i([starts, ends]).transpose() + + mentions = xp.concatenate((mentions, cments + total_length)) + total_length += len(doc) + + # TODO support attention here + tokvecs = xp.concatenate(tokvecs) + spans = [tokvecs[ii:jj] for ii, jj in mentions.tolist()] + avgs = [xp.mean(ss, axis=0) for ss in spans] + spanvecs = ops.asarray2f(avgs) + + # first and last token embeds + starts = [tokvecs[ii] for ii in mentions[:, 0]] + ends = [tokvecs[jj] for jj in mentions[:, 1]] + + starts = ops.asarray2f(starts) + ends = ops.asarray2f(ends) + concat = xp.concatenate((starts, ends, spanvecs), 1) + embeds = Ragged(concat, docmenlens) + + def backprop_span_embed(dY: SpanEmbeddings) -> Tuple[List[Floats2d], List[Doc]]: + + oweights = [] + odocs = [] + offset = 0 + tokoffset = 0 + for indoc, mlen in zip(docs, dY.vectors.lengths): + hi = offset + mlen + hitok = tokoffset + len(indoc) + odocs.append(indoc) # no change + vecs = dY.vectors.data[offset:hi] + + starts = vecs[:, :dim] + ends = vecs[:, dim : 2 * dim] + spanvecs = vecs[:, 2 * dim :] + + out = model.ops.alloc2f(len(indoc), dim) + + for ii, (start, end) in enumerate(dY.indices[offset:hi]): + # adjust indexes to align with doc + start -= tokoffset + end -= tokoffset + + out[start] += starts[ii] + out[end] += ends[ii] + out[start:end] += spanvecs[ii] + oweights.append(out) + + offset = hi + tokoffset = hitok + return oweights, odocs + + return SpanEmbeddings(mentions, embeds), backprop_span_embed + + +def build_coarse_pruner( + mention_limit: int, +) -> Model[SpanEmbeddings, SpanEmbeddings]: + model = Model( + "CoarsePruner", + forward=coarse_prune, + attrs={ + "mention_limit": mention_limit, + }, + ) + return model - tok2vec (Model[List[Doc], List[Floats2d]]): The token-to-vector subnetwork. + +def coarse_prune( + model, inputs: Tuple[Floats1d, SpanEmbeddings], is_train +) -> SpanEmbeddings: + """Given scores for mention, output the top non-crossing mentions. + + Mentions can contain other mentions, but candidate mentions cannot cross each other. """ - return tok2vec + rawscores, spanembeds = inputs + scores = rawscores.squeeze() + mention_limit = model.attrs["mention_limit"] + # XXX: Issue here. Don't need docs to find crossing spans, but might for the limits. + # In old code the limit can be: + # - hard number per doc + # - ratio of tokens in the doc + + offset = 0 + selected = [] + sellens = [] + for menlen in spanembeds.vectors.lengths: + hi = offset + menlen + cscores = scores[offset:hi] + + # negate it so highest numbers come first + tops = (model.ops.xp.argsort(-1 * cscores)).tolist() + starts = spanembeds.indices[offset:hi, 0].tolist() + ends = spanembeds.indices[offset:hi:, 1].tolist() + + # csel is a 1d integer list + csel = select_non_crossing_spans(tops, starts, ends, mention_limit) + # add the offset so these indices are absolute + csel = [ii + offset for ii in csel] + # this should be constant because short choices are padded + sellens.append(len(csel)) + selected += csel + offset += menlen + + selected = model.ops.asarray1i(selected) + top_spans = spanembeds.indices[selected] + top_vecs = spanembeds.vectors.data[selected] + + out = SpanEmbeddings(top_spans, Ragged(top_vecs, sellens)) + + def coarse_prune_backprop( + dY: Tuple[Floats1d, SpanEmbeddings] + ) -> Tuple[Floats1d, SpanEmbeddings]: + ll = spanembeds.indices.shape[0] + + dYscores, dYembeds = dY + + dXscores = model.ops.alloc1f(ll) + dXscores[selected] = dYscores.squeeze() + + dXvecs = model.ops.alloc2f(*spanembeds.vectors.data.shape) + dXvecs[selected] = dYembeds.vectors.data + rout = Ragged(dXvecs, out.vectors.lengths) + dXembeds = SpanEmbeddings(spanembeds.indices, rout) + + # inflate for mention scorer + dXscores = model.ops.xp.expand_dims(dXscores, 1) + + return (dXscores, dXembeds) + + return (scores[selected], out), coarse_prune_backprop + + +def build_take_vecs() -> Model[SpanEmbeddings, Floats2d]: + # this just gets vectors out of spanembeddings + # XXX Might be better to convert SpanEmbeddings to a tuple and use with_getitem + return Model("TakeVecs", forward=take_vecs_forward) + + +def take_vecs_forward(model, inputs: SpanEmbeddings, is_train) -> Floats2d: + def backprop(dY: Floats2d) -> SpanEmbeddings: + vecs = Ragged(dY, inputs.vectors.lengths) + return SpanEmbeddings(inputs.indices, vecs) + + return inputs.vectors.data, backprop + + +def build_ant_scorer( + bilinear, dropout, ant_limit=50 +) -> Model[Tuple[Floats1d, SpanEmbeddings], List[Floats2d]]: + return Model( + "AntScorer", + forward=ant_scorer_forward, + layers=[bilinear, dropout], + attrs={ + "ant_limit": ant_limit, + }, + ) + + +def ant_scorer_forward( + model, inputs: Tuple[Floats1d, SpanEmbeddings], is_train +) -> Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d]: + ops = model.ops + xp = ops.xp + + ant_limit = model.attrs["ant_limit"] + # this contains the coarse bilinear in coref-hoi + # coarse bilinear is a single layer linear network + # TODO make these proper refs + bilinear = model.layers[0] + dropout = model.layers[1] + + # XXX Note on dimensions: This won't work as a ragged because the floats2ds + # are not all the same dimentions. Each floats2d is a square in the size of + # the number of antecedents in the document. Actually, that will have the + # same size if antecedents are padded... Needs checking. + + mscores, sembeds = inputs + vecs = sembeds.vectors # ragged + + offset = 0 + backprops = [] + out = [] + for ll in vecs.lengths: + hi = offset + ll + # each iteration is one doc + + # first calculate the pairwise product scores + cvecs = vecs.data[offset:hi] + source, source_b = bilinear(cvecs, is_train) + target, target_b = dropout(cvecs, is_train) + pw_prod = xp.matmul(source, target.T) + + # now calculate the pairwise mention scores + ms = mscores[offset:hi].squeeze() + pw_sum = xp.expand_dims(ms, 1) + xp.expand_dims(ms, 0) + + # make a mask so antecedents precede referrents + ant_range = xp.arange(0, cvecs.shape[0]) + # with xp.errstate(divide="ignore"): + # mask = xp.log( + # (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 + # ).astype(float) + mask = xp.log( + (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 + ).astype(float) + + scores = pw_prod + pw_sum + mask + + top_scores, top_scores_idx = topk(xp, scores, ant_limit) + out.append((top_scores, top_scores_idx)) + + # In the full model these scores can be further refined. In the current + # state of this model we're done here, so this pruning is less important, + # but it's still helpful for reducing memory usage (since scores can be + # garbage collected when the loop exits). + + offset += ll + backprops.append((source_b, target_b, source, target)) + + def backprop( + dYs: Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d] + ) -> Tuple[Floats2d, SpanEmbeddings]: + dYscores, dYembeds = dYs + dXembeds = Ragged(ops.alloc2f(*vecs.data.shape), vecs.lengths) + dXscores = ops.alloc1f(*mscores.shape) + + offset = 0 + for dy, (source_b, target_b, source, target), ll in zip( + dYscores, backprops, vecs.lengths + ): + # I'm not undoing the operations in the right order here. + dyscore, dyidx = dy + # the full score grid is square + + fullscore = ops.alloc2f(ll, ll) + # cupy has no put_along_axis + # xp.put_along_axis(fullscore, dyidx, dyscore, 1) + for ii, (ridx, rscores) in enumerate(zip(dyidx, dyscore)): + fullscore[ii][ridx] = rscores + + dS = source_b(fullscore @ target) + dT = target_b(fullscore @ source) + dXembeds.data[offset : offset + ll] = dS + dT + + # The gradient can be distributed over all the rows and columns here, + # so aggregate it + section = dXscores[offset : offset + ll] + for ii in range(ll): + section[ii] = fullscore[:, ii].sum() + fullscore[ii, :].sum() + offset += ll + # make it fit back into the linear + dXscores = xp.expand_dims(dXscores, 1) + return (dXscores, SpanEmbeddings(sembeds.indices, dXembeds)) + + return (out, sembeds.indices), backprop diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py new file mode 100644 index 00000000000..7c44692c3b4 --- /dev/null +++ b/spacy/ml/models/coref_util.py @@ -0,0 +1,252 @@ +from thinc.types import Ints2d +from spacy.tokens import Doc +from typing import List, Tuple + +# type alias to make writing this less tedious +MentionClusters = List[List[Tuple[int, int]]] + +DEFAULT_CLUSTER_PREFIX = "coref_clusters" + + +def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: + """Given a doc, give the mention clusters. + + This is useful for scoring. + """ + out = [] + for name, val in doc.spans.items(): + if not name.startswith(prefix): + continue + + cluster = [] + for mention in val: + cluster.append((mention.start, mention.end)) + out.append(cluster) + return out + + +def topk(xp, arr, k, axis=None): + """Given and array and a k value, give the top values and idxs for each row.""" + + part = xp.argpartition(arr, -k, axis=1) + idxs = xp.flip(part)[:, :k] + + vals = xp.take_along_axis(arr, idxs, axis=1) + + sidxs = xp.argsort(vals, axis=1) + # map these idxs back to the original + oidxs = xp.take_along_axis(idxs, sidxs, axis=1) + svals = xp.take_along_axis(vals, sidxs, axis=1) + return svals, oidxs + + +def logsumexp(xp, arr, axis=None): + """Emulate torch.logsumexp by returning the log of summed exponentials + along each row in the given dimension. + + Reduces a 2d array to 1d.""" + # from slide 5 here: + # https://www.slideshare.net/ryokuta/cupy + hi = arr.max(axis=axis) + hi = xp.expand_dims(hi, 1) + return hi.squeeze() + xp.log(xp.exp(arr - hi).sum(axis=axis)) + + +# from model.py, refactored to be non-member +def get_predicted_antecedents(xp, antecedent_idx, antecedent_scores): + """Get the ID of the antecedent for each span. -1 if no antecedent.""" + predicted_antecedents = [] + for i, idx in enumerate(xp.argmax(antecedent_scores, axis=1) - 1): + if idx < 0: + predicted_antecedents.append(-1) + else: + predicted_antecedents.append(antecedent_idx[i][idx]) + return predicted_antecedents + + +# from model.py, refactored to be non-member +def get_predicted_clusters( + xp, span_starts, span_ends, antecedent_idx, antecedent_scores +): + """Convert predictions to usable cluster data. + + return values: + + clusters: a list of spans (i, j) that are a cluster + + Note that not all spans will be in the final output; spans with no + antecedent or referrent are omitted from clusters and mention2cluster. + """ + # Get predicted antecedents + predicted_antecedents = get_predicted_antecedents( + xp, antecedent_idx, antecedent_scores + ) + + # Get predicted clusters + mention_to_cluster_id = {} + predicted_clusters = [] + for i, predicted_idx in enumerate(predicted_antecedents): + if predicted_idx < 0: + continue + assert i > predicted_idx, f"span idx: {i}; antecedent idx: {predicted_idx}" + # Check antecedent's cluster + antecedent = (int(span_starts[predicted_idx]), int(span_ends[predicted_idx])) + antecedent_cluster_id = mention_to_cluster_id.get(antecedent, -1) + if antecedent_cluster_id == -1: + antecedent_cluster_id = len(predicted_clusters) + predicted_clusters.append([antecedent]) + mention_to_cluster_id[antecedent] = antecedent_cluster_id + # Add mention to cluster + mention = (int(span_starts[i]), int(span_ends[i])) + predicted_clusters[antecedent_cluster_id].append(mention) + mention_to_cluster_id[mention] = antecedent_cluster_id + + predicted_clusters = [tuple(c) for c in predicted_clusters] + return predicted_clusters + + +def get_sentence_map(doc: Doc): + """For the given span, return a list of sentence indexes.""" + + si = 0 + out = [] + for sent in doc.sents: + for tok in sent: + out.append(si) + si += 1 + return out + + +def get_candidate_mentions( + doc: Doc, max_span_width: int = 20 +) -> Tuple[List[int], List[int]]: + """Given a Doc, return candidate mentions. + + This isn't a trainable layer, it just returns raw candidates. + """ + # XXX Note that in coref-hoi the indexes are designed so you actually want [i:j+1], but here + # we're using [i:j], which is more natural. + + sentence_map = get_sentence_map(doc) + + begins = [] + ends = [] + for tok in doc: + si = sentence_map[tok.i] # sentence index + for ii in range(1, max_span_width): + ei = tok.i + ii # end index + if ei < len(doc) and sentence_map[ei] == si: + begins.append(tok.i) + ends.append(ei) + + return (begins, ends) + + +def select_non_crossing_spans( + idxs: List[int], starts: List[int], ends: List[int], limit: int +) -> List[int]: + """Given a list of spans sorted in descending order, return the indexes of + spans to keep, discarding spans that cross. + + Nested spans are allowed. + """ + # ported from Model._extract_top_spans + selected = [] + start_to_max_end = {} + end_to_min_start = {} + + for idx in idxs: + if len(selected) >= limit or idx > len(starts): + break + + start, end = starts[idx], ends[idx] + cross = False + + for ti in range(start, end + 1): + max_end = start_to_max_end.get(ti, -1) + if ti > start and max_end > end: + cross = True + break + + min_start = end_to_min_start.get(ti, -1) + if ti < end and 0 <= min_start < start: + cross = True + break + + if not cross: + # this index will be kept + # record it so we can exclude anything that crosses it + selected.append(idx) + max_end = start_to_max_end.get(start, -1) + if end > max_end: + start_to_max_end[start] = end + min_start = end_to_min_start.get(end, -1) + if start == -1 or start < min_start: + end_to_min_start[end] = start + + # sort idxs by order in doc + selected = sorted(selected, key=lambda idx: (starts[idx], ends[idx])) + while len(selected) < limit: + selected.append(selected[0]) # this seems a bit weird? + return selected + + +def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: + """Given a Doc, convert the cluster spans to simple int tuple lists.""" + out = [] + for key, val in doc.spans.items(): + cluster = [] + for span in val: + # TODO check that there isn't an off-by-one error here + cluster.append((span.start, span.end)) + out.append(cluster) + return out + + +def make_clean_doc(nlp, doc): + """Return a doc with raw data but not span annotations.""" + # Surely there is a better way to do this? + + sents = [tok.is_sent_start for tok in doc] + words = [tok.text for tok in doc] + out = Doc(nlp.vocab, words=words, sent_starts=sents) + return out + + +def create_gold_scores( + ments: Ints2d, clusters: List[List[Tuple[int, int]]] +) -> List[List[bool]]: + """Given mentions considered for antecedents and gold clusters, + construct a gold score matrix. This does not include the placeholder.""" + # make a mapping of mentions to cluster id + # id is not important but equality will be + ment2cid = {} + for cid, cluster in enumerate(clusters): + for ment in cluster: + ment2cid[ment] = cid + + ll = len(ments) + out = [] + # The .tolist() call is necessary with cupy but not numpy + mentuples = [tuple(mm.tolist()) for mm in ments] + for ii, ment in enumerate(mentuples): + if ment not in ment2cid: + # this is not in a cluster so it has no antecedent + out.append([False] * ll) + continue + + # this might change if no real antecedent is a candidate + row = [] + cid = ment2cid[ment] + for jj, ante in enumerate(mentuples): + # antecedents must come first + if jj >= ii: + row.append(False) + continue + + row.append(cid == ment2cid.get(ante, -1)) + + out.append(row) + + # caller needs to convert to array, and add placeholder + return out diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 9ccc2c89f0a..d0fecf519eb 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -1,6 +1,7 @@ -from typing import Iterable, Tuple, Optional, Dict, Callable, Any +from typing import Iterable, Tuple, Optional, Dict, Callable, Any, List -from thinc.api import get_array_module, Model, Optimizer, set_dropout_rate, Config +from thinc.types import Floats2d, Ints2d +from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy from itertools import islice from .trainable_pipe import TrainablePipe @@ -12,10 +13,25 @@ from ..tokens import Doc from ..vocab import Vocab +from ..ml.models.coref_util import ( + create_gold_scores, + MentionClusters, + get_clusters_from_doc, + logsumexp, + get_predicted_clusters, + DEFAULT_CLUSTER_PREFIX, + doc2clusters, +) + default_config = """ [model] @architectures = "spacy.Coref.v0" +max_span_width = 20 +mention_limit = 3900 +dropout = 0.3 +hidden = 1000 +@get_mentions = "spacy.CorefCandidateGenerator.v0" [model.tok2vec] @architectures = "spacy.Tok2Vec.v2" @@ -41,12 +57,11 @@ @Language.factory( "coref", - assigns=[f"doc.spans"], + assigns=["doc.spans"], requires=["doc.spans"], default_config={ "model": DEFAULT_MODEL, - "span_mentions": DEFAULT_MENTIONS, - "span_cluster_prefix": DEFAULT_CLUSTERS_PREFIX, + "span_cluster_prefix": DEFAULT_CLUSTER_PREFIX, }, default_score_weights={"coref_f": 1.0, "coref_p": None, "coref_r": None}, ) @@ -54,21 +69,11 @@ def make_coref( nlp: Language, name: str, model, - span_mentions: str, - span_cluster_prefix: str, + span_cluster_prefix: str = "coref", ) -> "CoreferenceResolver": - """Create a CoreferenceResolver component. TODO + """Create a CoreferenceResolver component.""" - model (Model[List[Doc], List[Floats2d]]): A model instance that predicts ... - threshold (float): Cutoff to consider a prediction "positive". - """ - return CoreferenceResolver( - nlp.vocab, - model, - name, - span_mentions=span_mentions, - span_cluster_prefix=span_cluster_prefix, - ) + return CoreferenceResolver(nlp.vocab, model, name, span_cluster_prefix) class CoreferenceResolver(TrainablePipe): @@ -105,9 +110,11 @@ def __init__( self.span_mentions = span_mentions self.span_cluster_prefix = span_cluster_prefix self._rehearsal_model = None + self.loss = CategoricalCrossentropy() + self.cfg = {} - def predict(self, docs: Iterable[Doc]): + def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: """Apply the pipeline's model to a batch of docs, without modifying them. TODO: write actual algorithm @@ -116,12 +123,27 @@ def predict(self, docs: Iterable[Doc]): DOCS: https://spacy.io/api/coref#predict (TODO) """ + scores, idxs = self.model.predict(docs) + # idxs is a list of mentions (start / end idxs) + # each item in scores includes scores and a mapping from scores to mentions + + xp = self.model.ops.xp + clusters_by_doc = [] - for i, doc in enumerate(docs): - clusters = [] - for span in doc.spans[self.span_mentions]: - clusters.append([span]) - clusters_by_doc.append(clusters) + offset = 0 + for cscores, ant_idxs in scores: + ll = cscores.shape[0] + hi = offset + ll + + starts = idxs[offset:hi, 0] + ends = idxs[offset:hi, 1] + + # need to add the placeholder + placeholder = self.model.ops.alloc2f(cscores.shape[0], 1) + cscores = xp.concatenate((placeholder, cscores), 1) + + predicted = get_predicted_clusters(xp, starts, ends, ant_idxs, cscores) + clusters_by_doc.append(predicted) return clusters_by_doc def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: @@ -133,18 +155,24 @@ def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: DOCS: https://spacy.io/api/coref#set_annotations (TODO) """ if len(docs) != len(clusters_by_doc): - raise ValueError("Found coref clusters incompatible with the " - "documents provided to the 'coref' component. " - "This is likely a bug in spaCy.") + raise ValueError( + "Found coref clusters incompatible with the " + "documents provided to the 'coref' component. " + "This is likely a bug in spaCy." + ) for doc, clusters in zip(docs, clusters_by_doc): - index = 0 - for cluster in clusters: - key = self.span_cluster_prefix + str(index) + for ii, cluster in enumerate(clusters): + key = self.span_cluster_prefix + "_" + str(ii) if key in doc.spans: - raise ValueError(f"Couldn't store the results of {self.name}, as the key " - f"{key} already exists in 'doc.spans'.") - doc.spans[key] = cluster - index += 1 + raise ValueError( + "Found coref clusters incompatible with the " + "documents provided to the 'coref' component. " + "This is likely a bug in spaCy." + ) + + doc.spans[key] = [] + for mention in cluster: + doc.spans[key].append(doc[mention[0] : mention[1]]) def update( self, @@ -174,13 +202,16 @@ def update( # Handle cases where there are no tokens in any docs. return losses set_dropout_rate(self.model, drop) - scores, bp_scores = self.model.begin_update([eg.predicted for eg in examples]) - # TODO below - # loss, d_scores = self.get_loss(examples, scores) - # bp_scores(d_scores) + + inputs = (example.predicted for example in examples) + preds, backprop = self.model.begin_update(inputs) + score_matrix, mention_idx = preds + loss, d_scores = self.get_loss(examples, score_matrix, mention_idx) + backprop(d_scores) + if sgd is not None: self.finish_update(sgd) - # losses[self.name] += loss + losses[self.name] += loss return losses def rehearse( @@ -236,7 +267,12 @@ def add_label(self, label: str) -> int: ) ) - def get_loss(self, examples: Iterable[Example], scores) -> Tuple[float, float]: + def get_loss( + self, + examples: Iterable[Example], + score_matrix: List[Tuple[Floats2d, Ints2d]], + mention_idx: Ints2d, + ): """Find the loss and gradient of loss for the batch of documents and their predicted scores. @@ -246,9 +282,46 @@ def get_loss(self, examples: Iterable[Example], scores) -> Tuple[float, float]: DOCS: https://spacy.io/api/coref#get_loss (TODO) """ - validate_examples(examples, "CoreferenceResolver.get_loss") - # TODO - return None + ops = self.model.ops + xp = ops.xp + + offset = 0 + gradients = [] + loss = 0 + for example, (cscores, cidx) in zip(examples, score_matrix): + # assume cids has absolute mention ids + + ll = cscores.shape[0] + hi = offset + ll + + clusters = get_clusters_from_doc(example.reference) + gscores = create_gold_scores(mention_idx[offset:hi], clusters) + gscores = xp.asarray(gscores) + top_gscores = xp.take_along_axis(gscores, cidx, axis=1) + # now add the placeholder + gold_placeholder = ~top_gscores.any(axis=1).T + gold_placeholder = xp.expand_dims(gold_placeholder, 1) + top_gscores = xp.concatenate((gold_placeholder, top_gscores), 1) + + # boolean to float + top_gscores = ops.asarray2f(top_gscores) + + # add the placeholder to cscores + placeholder = self.model.ops.alloc2f(ll, 1) + cscores = xp.concatenate((placeholder, cscores), 1) + + # do softmax to cscores + cscores = ops.softmax(cscores, axis=1) + + diff = self.loss.get_grad(cscores, top_gscores) + diff = diff[:, 1:] + gradients.append((diff, cidx)) + + # scalar loss + # loss += xp.sum(log_norm - log_marg) + loss += self.loss.get_loss(cscores, top_gscores) + offset += ll + return loss, gradients def initialize( self, @@ -279,10 +352,39 @@ def score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: DOCS: https://spacy.io/api/coref#score (TODO) """ + def clusters_getter(doc, span_key): - return [spans for name, spans in doc.spans.items() if name.startswith(span_key)] + return [ + spans for name, spans in doc.spans.items() if name.startswith(span_key) + ] + validate_examples(examples, "CoreferenceResolver.score") kwargs.setdefault("getter", clusters_getter) kwargs.setdefault("attr", self.span_cluster_prefix) kwargs.setdefault("include_label", False) return Scorer.score_clusters(examples, **kwargs) + + +# from ..coref_scorer import Evaluator, get_cluster_info, b_cubed +# TODO consider whether to use this +# def score(self, examples, **kwargs): +# """Score a batch of examples.""" +# +# #TODO traditionally coref uses the average of b_cubed, muc, and ceaf. +# # we need to handle the average ourselves. +# evaluator = Evaluator(b_cubed) +# +# for ex in examples: +# p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) +# g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) +# +# cluster_info = get_cluster_info(p_clusters, g_clusters) +# +# evaluator.update(cluster_info) +# +# scores ={ +# "coref_f": evaluator.get_f1(), +# "coref_p": evaluator.get_precision(), +# "coref_r": evaluator.get_recall(), +# } +# return scores From 91b111467bf9afaec93d4c3de86c8a1877587783 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 17 May 2021 14:52:30 +0900 Subject: [PATCH 003/188] Minor fixes --- spacy/ml/models/coref_util.py | 3 ++- spacy/pipeline/coref.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 7c44692c3b4..6ce5127b095 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -1,6 +1,7 @@ from thinc.types import Ints2d from spacy.tokens import Doc -from typing import List, Tuple +from typing import List, Tuple, Callable +from ...util import registry # type alias to make writing this less tedious MentionClusters = List[List[Tuple[int, int]]] diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index d0fecf519eb..ac225d677c4 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -73,7 +73,7 @@ def make_coref( ) -> "CoreferenceResolver": """Create a CoreferenceResolver component.""" - return CoreferenceResolver(nlp.vocab, model, name, span_cluster_prefix) + return CoreferenceResolver(nlp.vocab, model, name, span_cluster_prefix=span_cluster_prefix) class CoreferenceResolver(TrainablePipe): @@ -88,7 +88,7 @@ def __init__( model: Model, name: str = "coref", *, - span_mentions: str, + span_mentions: str = "coref_mentions", span_cluster_prefix: str, ) -> None: """Initialize a coreference resolution component. From e303628205fe124a829ca95e00560f9bc4b76f6e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 17 May 2021 14:52:48 +0900 Subject: [PATCH 004/188] Attempt to use registry correctly --- spacy/ml/models/coref_util.py | 5 +++++ spacy/pipeline/coref.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 6ce5127b095..89b39f8e6a4 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -143,6 +143,11 @@ def get_candidate_mentions( return (begins, ends) +@registry.misc("spacy.CorefCandidateGenerator.v0") +def create_mention_generator() -> Callable: + return get_candidate_mentions + + def select_non_crossing_spans( idxs: List[int], starts: List[int], ends: List[int], limit: int ) -> List[int]: diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index ac225d677c4..b11f1607ea6 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -31,7 +31,9 @@ mention_limit = 3900 dropout = 0.3 hidden = 1000 -@get_mentions = "spacy.CorefCandidateGenerator.v0" + +[model.get_mentions] +@misc = "spacy.CorefCandidateGenerator.v0" [model.tok2vec] @architectures = "spacy.Tok2Vec.v2" From 051715506e1c243071e82b0ac5c93a0691b990e7 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 18 May 2021 19:53:33 +0900 Subject: [PATCH 005/188] Fiddle with get_mentions definition Ended up not making a difference, but oh well. --- spacy/ml/models/coref.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index ba522b1f24b..422c2e38a11 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -2,7 +2,7 @@ from thinc.api import Model, Linear, Relu, Dropout, chain, noop from thinc.types import Floats2d, Floats1d, Ints2d, Ragged -from typing import List, Callable, Tuple +from typing import List, Callable, Tuple, Any, Generator from ...tokens import Doc from ...util import registry @@ -20,7 +20,7 @@ @registry.architectures("spacy.Coref.v0") def build_coref( tok2vec: Model[List[Doc], List[Floats2d]], - get_mentions: Callable = get_candidate_mentions, + get_mentions: Any = get_candidate_mentions, hidden: int = 1000, dropout: float = 0.3, mention_limit: int = 3900, From 883c137b263708e9063313cc77e517490e6972be Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 18 May 2021 19:53:59 +0900 Subject: [PATCH 006/188] Add basic tuplify init --- spacy/ml/models/coref.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 422c2e38a11..928e84985bc 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -62,6 +62,7 @@ def tuplify(layer1: Model, layer2: Model, *layers) -> Model: return Model( "tuple(" + ", ".join(names) + ")", tuplify_forward, + init=tuplify_init, layers=layers, ) @@ -83,6 +84,17 @@ def backprop_tuplify(dYs): return tuple(Ys), backprop_tuplify +#TODO make more robust, see chain +def tuplify_init(model, X, Y) -> Model: + if X is None and Y is None: + for layer in model.layers: + layer.initialize() + + return model + + for layer in model.layers: + layer.initialize(X=X) + return model @dataclass class SpanEmbeddings: From a7d9c8156df4e78637aaa3f12dc573c56ef27312 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 18 May 2021 19:54:54 +0900 Subject: [PATCH 007/188] Make get_sentence_map work with init When sentences are not available, just treat the whole doc as one sentence. A reasonable general fallback, but important due to the init call, where upstream components aren't run. --- spacy/ml/models/coref.py | 1 + spacy/ml/models/coref_util.py | 23 ++++++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 928e84985bc..cc352435b16 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -145,6 +145,7 @@ def span_embeddings_forward( tokvecs, docs = inputs + #TODO fix this dim = tokvecs[0].shape[1] get_mentions = model.attrs["get_mentions"] diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 89b39f8e6a4..624f89a7093 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -1,6 +1,6 @@ from thinc.types import Ints2d from spacy.tokens import Doc -from typing import List, Tuple, Callable +from typing import List, Tuple, Callable, Any from ...util import registry # type alias to make writing this less tedious @@ -109,13 +109,18 @@ def get_predicted_clusters( def get_sentence_map(doc: Doc): """For the given span, return a list of sentence indexes.""" - si = 0 - out = [] - for sent in doc.sents: - for tok in sent: - out.append(si) - si += 1 - return out + try: + si = 0 + out = [] + for sent in doc.sents: + for tok in sent: + out.append(si) + si += 1 + return out + except ValueError: + # If there are no sents then just return dummy values. + # Shouldn't happen in general training, but typical in init. + return [0] * len(doc) def get_candidate_mentions( @@ -144,7 +149,7 @@ def get_candidate_mentions( @registry.misc("spacy.CorefCandidateGenerator.v0") -def create_mention_generator() -> Callable: +def create_mention_generator() -> Any: return get_candidate_mentions From 0620820857b1e5a8e930b3ce75efbef4023c9a7b Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 18 May 2021 19:55:52 +0900 Subject: [PATCH 008/188] Deal with generators in tuplify --- spacy/ml/models/coref.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index cc352435b16..d02c5077113 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -70,6 +70,10 @@ def tuplify(layer1: Model, layer2: Model, *layers) -> Model: def tuplify_forward(model, X, is_train): Ys = [] backprops = [] + # If the input is a generator we need to unroll it. + # The type check is necessary because arrays etc. are also OK. + if isinstance(X, Generator): + X = list(X) for layer in model.layers: Y, backprop = layer(X, is_train) Ys.append(Y) From 2486b8ad4dd985596df5366da133863a551f0363 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 18 May 2021 19:56:27 +0900 Subject: [PATCH 009/188] Fix pipeline intialize --- spacy/pipeline/coref.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index b11f1607ea6..503431d9088 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -2,6 +2,7 @@ from thinc.types import Floats2d, Ints2d from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy +from thinc.api import set_dropout_rate from itertools import islice from .trainable_pipe import TrainablePipe @@ -341,10 +342,15 @@ def initialize( DOCS: https://spacy.io/api/coref#initialize (TODO) """ validate_get_examples(get_examples, "CoreferenceResolver.initialize") - subbatch = list(islice(get_examples(), 10)) - doc_sample = [eg.reference for eg in subbatch] - assert len(doc_sample) > 0, Errors.E923.format(name=self.name) - self.model.initialize(X=doc_sample) + + X = [] + Y = [] + for ex in islice(get_examples(), 10): + X.append(ex.predicted) + Y.append(ex.reference) + + assert len(X) > 0, Errors.E923.format(name=self.name) + self.model.initialize(X=X, Y=Y) def score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: """Score a batch of examples. From d22acee4f76e460c06e9fcc84204d08ac20f2504 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 18 May 2021 20:09:27 +0900 Subject: [PATCH 010/188] Fix backprop Training seems to actually run now! --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 503431d9088..8c001280a00 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -210,7 +210,7 @@ def update( preds, backprop = self.model.begin_update(inputs) score_matrix, mention_idx = preds loss, d_scores = self.get_loss(examples, score_matrix, mention_idx) - backprop(d_scores) + backprop((d_scores, mention_idx)) if sgd is not None: self.finish_update(sgd) From fa92daf052f54770fcfc694bfdac4ede4fc64dfe Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 20 May 2021 15:59:51 +0900 Subject: [PATCH 011/188] Break pairwise operations into pseudolayers This makes their scope tighter and more contained, and has the nice side effect that fewer things need to be passed around for backprop. --- spacy/ml/models/coref.py | 67 +++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index d02c5077113..f6437794401 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -88,7 +88,8 @@ def backprop_tuplify(dYs): return tuple(Ys), backprop_tuplify -#TODO make more robust, see chain + +# TODO make more robust, see chain def tuplify_init(model, X, Y) -> Model: if X is None and Y is None: for layer in model.layers: @@ -149,7 +150,7 @@ def span_embeddings_forward( tokvecs, docs = inputs - #TODO fix this + # TODO fix this dim = tokvecs[0].shape[1] get_mentions = model.attrs["get_mentions"] @@ -157,6 +158,7 @@ def span_embeddings_forward( mentions = ops.alloc2i(0, 2) total_length = 0 docmenlens = [] # number of mentions per doc + for doc in docs: starts, ends = get_mentions(doc, max_span_width) docmenlens.append(len(starts)) @@ -350,13 +352,11 @@ def ant_scorer_forward( # first calculate the pairwise product scores cvecs = vecs.data[offset:hi] - source, source_b = bilinear(cvecs, is_train) - target, target_b = dropout(cvecs, is_train) - pw_prod = xp.matmul(source, target.T) + pw_prod, prod_back = pairwise_product(bilinear, dropout, cvecs, is_train) # now calculate the pairwise mention scores ms = mscores[offset:hi].squeeze() - pw_sum = xp.expand_dims(ms, 1) + xp.expand_dims(ms, 0) + pw_sum, pw_sum_back = pairwise_sum(ops, ms) # make a mask so antecedents precede referrents ant_range = xp.arange(0, cvecs.shape[0]) @@ -379,7 +379,7 @@ def ant_scorer_forward( # garbage collected when the loop exits). offset += ll - backprops.append((source_b, target_b, source, target)) + backprops.append((prod_back, pw_sum_back)) def backprop( dYs: Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d] @@ -389,9 +389,7 @@ def backprop( dXscores = ops.alloc1f(*mscores.shape) offset = 0 - for dy, (source_b, target_b, source, target), ll in zip( - dYscores, backprops, vecs.lengths - ): + for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, vecs.lengths): # I'm not undoing the operations in the right order here. dyscore, dyidx = dy # the full score grid is square @@ -402,18 +400,51 @@ def backprop( for ii, (ridx, rscores) in enumerate(zip(dyidx, dyscore)): fullscore[ii][ridx] = rscores - dS = source_b(fullscore @ target) - dT = target_b(fullscore @ source) - dXembeds.data[offset : offset + ll] = dS + dT + dXembeds.data[offset : offset + ll] = prod_back(fullscore) + dXscores[offset : offset + ll] = pw_sum_back(fullscore) - # The gradient can be distributed over all the rows and columns here, - # so aggregate it - section = dXscores[offset : offset + ll] - for ii in range(ll): - section[ii] = fullscore[:, ii].sum() + fullscore[ii, :].sum() offset += ll # make it fit back into the linear dXscores = xp.expand_dims(dXscores, 1) return (dXscores, SpanEmbeddings(sembeds.indices, dXembeds)) return (out, sembeds.indices), backprop + + +def pairwise_sum(ops, mention_scores: Floats1d) -> Tuple[Floats2d, Callable]: + """Find the most likely mention-antecedent pairs.""" + # This doesn't use multiplication because two items with low mention scores + # don't make a good candidate pair. + + pw_sum = ops.xp.expand_dims(mention_scores, 1) + ops.xp.expand_dims( + mention_scores, 0 + ) + + def backward(d_pwsum: Floats2d) -> Floats1d: + # For the backward pass, the gradient is distributed over the whole row and + # column, so pull it all in. + dim = d_pwsum.shape[0] + out = ops.alloc1f(dim) + for ii in range(dim): + out[ii] = d_pwsum[:, ii].sum() + d_pwsum[ii, :].sum() + #XXX maybe subtract d_pwsum[ii,ii] to avoid double counting? + + return out + + return pw_sum, backward + + +def pairwise_product(bilinear, dropout, vecs: Floats2d, is_train): + # A neat side effect of this is that we don't have to pass the backprops + # around separately because the closure handles them. + source, source_b = bilinear(vecs, is_train) + target, target_b = dropout(vecs, is_train) + pw_prod = bilinear.ops.xp.matmul(source, target.T) + + def backward(d_prod: Floats2d) -> Floats2d: + dS = source_b(d_prod @ target) + dT = target_b(d_prod @ source) + dX = dS + dT + return dX + + return pw_prod, backward From 8c5df622d8149426a75a9eac3cc5d46fe44aea71 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 20 May 2021 16:40:55 +0900 Subject: [PATCH 012/188] Help out python gc in coref backprop --- spacy/ml/models/coref.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index f6437794401..0f544ed2bf1 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -101,6 +101,7 @@ def tuplify_init(model, X, Y) -> Model: layer.initialize(X=X) return model + @dataclass class SpanEmbeddings: indices: Ints2d # Array with 2 columns (for start and end index) @@ -272,20 +273,25 @@ def coarse_prune( out = SpanEmbeddings(top_spans, Ragged(top_vecs, sellens)) + # save some variables so the embeds can be garbage collected + idxlen = spanembeds.indices.shape[0] + vecshape = spanembeds.vectors.data.shape + indices = spanembeds.indices + veclens = out.vectors.lengths + def coarse_prune_backprop( dY: Tuple[Floats1d, SpanEmbeddings] ) -> Tuple[Floats1d, SpanEmbeddings]: - ll = spanembeds.indices.shape[0] dYscores, dYembeds = dY - dXscores = model.ops.alloc1f(ll) + dXscores = model.ops.alloc1f(idxlen) dXscores[selected] = dYscores.squeeze() - dXvecs = model.ops.alloc2f(*spanembeds.vectors.data.shape) + dXvecs = model.ops.alloc2f(*vecshape) dXvecs[selected] = dYembeds.vectors.data - rout = Ragged(dXvecs, out.vectors.lengths) - dXembeds = SpanEmbeddings(spanembeds.indices, rout) + rout = Ragged(dXvecs, veclens) + dXembeds = SpanEmbeddings(indices, rout) # inflate for mention scorer dXscores = model.ops.xp.expand_dims(dXscores, 1) @@ -381,15 +387,20 @@ def ant_scorer_forward( offset += ll backprops.append((prod_back, pw_sum_back)) + # save vars for gc + vecshape = vecs.data.shape + veclens = vecs.lengths + scoreshape = mscores.shape + def backprop( dYs: Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d] ) -> Tuple[Floats2d, SpanEmbeddings]: dYscores, dYembeds = dYs - dXembeds = Ragged(ops.alloc2f(*vecs.data.shape), vecs.lengths) - dXscores = ops.alloc1f(*mscores.shape) + dXembeds = Ragged(ops.alloc2f(*vecshape), veclens) + dXscores = ops.alloc1f(*scoreshape) offset = 0 - for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, vecs.lengths): + for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, veclens): # I'm not undoing the operations in the right order here. dyscore, dyidx = dy # the full score grid is square @@ -427,7 +438,7 @@ def backward(d_pwsum: Floats2d) -> Floats1d: out = ops.alloc1f(dim) for ii in range(dim): out[ii] = d_pwsum[:, ii].sum() + d_pwsum[ii, :].sum() - #XXX maybe subtract d_pwsum[ii,ii] to avoid double counting? + # XXX maybe subtract d_pwsum[ii,ii] to avoid double counting? return out From ff3fed06cf5733647122718a0bf7d842a1d497a0 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 20 May 2021 21:30:46 +0900 Subject: [PATCH 013/188] Catch a stray reference --- spacy/ml/models/coref.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 0f544ed2bf1..d4a28c0e425 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -391,6 +391,7 @@ def ant_scorer_forward( vecshape = vecs.data.shape veclens = vecs.lengths scoreshape = mscores.shape + idxes = sembeds.indices def backprop( dYs: Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d] @@ -417,7 +418,7 @@ def backprop( offset += ll # make it fit back into the linear dXscores = xp.expand_dims(dXscores, 1) - return (dXscores, SpanEmbeddings(sembeds.indices, dXembeds)) + return (dXscores, SpanEmbeddings(idxes, dXembeds)) return (out, sembeds.indices), backprop From e1b4a85bb910161b5300e746e825e453576eec38 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 21 May 2021 15:46:50 +0900 Subject: [PATCH 014/188] Fix loss The loss was being returned as a single element array, which caused training to die when it attempted to turn it into JSON. --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 8c001280a00..1b8a882d8bc 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -322,7 +322,7 @@ def get_loss( # scalar loss # loss += xp.sum(log_norm - log_marg) - loss += self.loss.get_loss(cscores, top_gscores) + loss += float(self.loss.get_loss(cscores, top_gscores)) offset += ll return loss, gradients From f6652c92521fd545170edd6bae826eda3d265795 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 21 May 2021 15:56:40 +0900 Subject: [PATCH 015/188] Add new coref scoring This is closer to the traditional evaluation method. That uses an average of three scores, this is just using the bcubed metric for now (nothing special about bcubed, just picked one). The scoring implementation comes from the coval project. It relies on scipy, which is one issue, and is rather involved, which is another. Besides being comparable with traditional evaluations, this scoring is relatively fast. --- spacy/coref_scorer.py | 248 ++++++++++++++++++++++++++++++++++++++++ spacy/pipeline/coref.py | 51 +++++---- 2 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 spacy/coref_scorer.py diff --git a/spacy/coref_scorer.py b/spacy/coref_scorer.py new file mode 100644 index 00000000000..e00b22fd709 --- /dev/null +++ b/spacy/coref_scorer.py @@ -0,0 +1,248 @@ +# copied from coval +# https://github.com/ns-moosavi/coval +from collections import Counter +import numpy as np + +try: + # This is only used in the ceaf methods. If those are necessary we should + # implement this locally to avoid a scipy dep. + from scipy.optimize import linear_sum_assignment +except: + pass + +# Terminology here is consistent with papers in the field but kind of confusing. +# Key = gold data, System = predictions. + + +def get_cluster_info(predicted_clusters, gold_clusters): + p2g = get_markable_assignments(predicted_clusters, gold_clusters) + g2p = get_markable_assignments(gold_clusters, predicted_clusters) + # this is the data format used as input by the evaluator + return (gold_clusters, predicted_clusters, g2p, p2g) + + +def get_markable_assignments(inp_clusters, out_clusters): + markable_cluster_ids = {} + out_dic = {} + for cluster_id, cluster in enumerate(out_clusters): + for m in cluster: + out_dic[m] = cluster_id + + for cluster in inp_clusters: + for im in cluster: + for om in out_dic: + if im == om: + markable_cluster_ids[im] = out_dic[om] + break + + return markable_cluster_ids + + +def f1(p_num, p_den, r_num, r_den, beta=1): + p = 0 if p_den == 0 else p_num / float(p_den) + r = 0 if r_den == 0 else r_num / float(r_den) + return 0 if p + r == 0 else (1 + beta * beta) * p * r / (beta * beta * p + r) + + +def evaluate_non_referrings(doc_non_referring_infos): + tp, _tn, fp, fn = 0, 0, 0, 0 + + for doc_id in doc_non_referring_infos: + key_non_referrings, sys_non_referrings = doc_non_referring_infos[doc_id] + for m in key_non_referrings: + if m in sys_non_referrings: + tp += 1 + else: + fn += 1 + for m in sys_non_referrings: + if m not in key_non_referrings: + fp += 1 + + recall = tp / float(tp + fn) if (tp + fn) > 0 else 0 + precision = tp / float(tp + fp) if (tp + fp) > 0 else 0 + f1 = ( + 2 * recall * precision / (recall + precision) if (recall + precision) > 0 else 0 + ) + + return recall, precision, f1 + + +class Evaluator: + def __init__(self, metric, beta=1, keep_aggregated_values=False): + self.p_num = 0 + self.p_den = 0 + self.r_num = 0 + self.r_den = 0 + self.metric = metric + self.beta = beta + self.keep_aggregated_values = keep_aggregated_values + + if keep_aggregated_values: + self.aggregated_p_num = [] + self.aggregated_p_den = [] + self.aggregated_r_num = [] + self.aggregated_r_den = [] + + def update(self, coref_info): + ( + key_clusters, + sys_clusters, + key_mention_sys_cluster, + sys_mention_key_cluster, + ) = coref_info + + if self.metric == ceafe or self.metric == ceafm: + pn, pd, rn, rd = self.metric(sys_clusters, key_clusters) + elif self.metric == lea: + pn, pd = self.metric(sys_clusters, key_clusters, sys_mention_key_cluster) + rn, rd = self.metric(key_clusters, sys_clusters, key_mention_sys_cluster) + else: + pn, pd = self.metric(sys_clusters, sys_mention_key_cluster) + rn, rd = self.metric(key_clusters, key_mention_sys_cluster) + self.p_num += pn + self.p_den += pd + self.r_num += rn + self.r_den += rd + + if self.keep_aggregated_values: + self.aggregated_p_num.append(pn) + self.aggregated_p_den.append(pd) + self.aggregated_r_num.append(rn) + self.aggregated_r_den.append(rd) + + def get_f1(self): + return f1(self.p_num, self.p_den, self.r_num, self.r_den, beta=self.beta) + + def get_recall(self): + return 0 if self.r_num == 0 else self.r_num / float(self.r_den) + + def get_precision(self): + return 0 if self.p_num == 0 else self.p_num / float(self.p_den) + + def get_prf(self): + return self.get_precision(), self.get_recall(), self.get_f1() + + def get_counts(self): + return self.p_num, self.p_den, self.r_num, self.r_den + + def get_aggregated_values(self): + return ( + self.aggregated_p_num, + self.aggregated_p_den, + self.aggregated_r_num, + self.aggregated_r_den, + ) + + +def evaluate_documents(doc_coref_infos, metric, beta=1): + evaluator = Evaluator(metric, beta=beta) + for doc_id in doc_coref_infos: + evaluator.update(doc_coref_infos[doc_id]) + return (evaluator.get_recall(), evaluator.get_precision(), evaluator.get_f1()) + + +def get_document_evaluations(doc_coref_infos, metric, beta=1): + evaluator = Evaluator(metric, beta=beta, keep_aggregated_values=True) + for doc_id in doc_coref_infos: + evaluator.update(doc_coref_infos[doc_id]) + return evaluator.get_aggregated_values() + + +def mentions(clusters, mention_to_gold): + setofmentions = set(mention for cluster in clusters for mention in cluster) + correct = setofmentions & set(mention_to_gold.keys()) + return len(correct), len(setofmentions) + + +def b_cubed(clusters, mention_to_gold): + num, den = 0, 0 + + for c in clusters: + gold_counts = Counter() + correct = 0 + for m in c: + if m in mention_to_gold: + gold_counts[mention_to_gold[m]] += 1 + for c2 in gold_counts: + correct += gold_counts[c2] * gold_counts[c2] + + num += correct / float(len(c)) + den += len(c) + + return num, den + + +def muc(clusters, mention_to_gold): + tp, p = 0, 0 + for c in clusters: + p += len(c) - 1 + tp += len(c) + linked = set() + for m in c: + if m in mention_to_gold: + linked.add(mention_to_gold[m]) + else: + tp -= 1 + tp -= len(linked) + return tp, p + + +def phi4(c1, c2): + return 2 * len([m for m in c1 if m in c2]) / float(len(c1) + len(c2)) + + +def phi3(c1, c2): + return len([m for m in c1 if m in c2]) + + +def ceafe(clusters, gold_clusters): + clusters = [c for c in clusters] + scores = np.zeros((len(gold_clusters), len(clusters))) + for i in range(len(gold_clusters)): + for j in range(len(clusters)): + scores[i, j] = phi4(gold_clusters[i], clusters[j]) + row_ind, col_ind = linear_sum_assignment(-scores) + similarity = scores[row_ind, col_ind].sum() + return similarity, len(clusters), similarity, len(gold_clusters) + + +def ceafm(clusters, gold_clusters): + clusters = [c for c in clusters] + scores = np.zeros((len(gold_clusters), len(clusters))) + for i in range(len(gold_clusters)): + for j in range(len(clusters)): + scores[i, j] = phi3(gold_clusters[i], clusters[j]) + row_ind, col_ind = linear_sum_assignment(-scores) + similarity = scores[row_ind, col_ind].sum() + return similarity, len(clusters), similarity, len(gold_clusters) + + +def lea(input_clusters, output_clusters, mention_to_gold): + num, den = 0, 0 + + for c in input_clusters: + if len(c) == 1: + all_links = 1 + if ( + c[0] in mention_to_gold + and len(output_clusters[mention_to_gold[c[0]]]) == 1 + ): + common_links = 1 + else: + common_links = 0 + else: + common_links = 0 + all_links = len(c) * (len(c) - 1) / 2.0 + for i, m in enumerate(c): + if m in mention_to_gold: + for m2 in c[i + 1 :]: + if ( + m2 in mention_to_gold + and mention_to_gold[m] == mention_to_gold[m2] + ): + common_links += 1 + + num += len(c) * common_links / float(all_links) + den += len(c) + + return num, den diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 1b8a882d8bc..841f3d7a6e9 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -24,6 +24,7 @@ doc2clusters, ) +from ..coref_scorer import Evaluator, get_cluster_info, b_cubed default_config = """ [model] @@ -352,7 +353,7 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) - def score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: + def alt_score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: """Score a batch of examples. examples (Iterable[Example]): The examples to score. @@ -373,26 +374,28 @@ def clusters_getter(doc, span_key): return Scorer.score_clusters(examples, **kwargs) -# from ..coref_scorer import Evaluator, get_cluster_info, b_cubed -# TODO consider whether to use this -# def score(self, examples, **kwargs): -# """Score a batch of examples.""" -# -# #TODO traditionally coref uses the average of b_cubed, muc, and ceaf. -# # we need to handle the average ourselves. -# evaluator = Evaluator(b_cubed) -# -# for ex in examples: -# p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) -# g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) -# -# cluster_info = get_cluster_info(p_clusters, g_clusters) -# -# evaluator.update(cluster_info) -# -# scores ={ -# "coref_f": evaluator.get_f1(), -# "coref_p": evaluator.get_precision(), -# "coref_r": evaluator.get_recall(), -# } -# return scores + # TODO consider whether to use this. It's pretty fast, but it'll be slower if + # we use all three methods like the original evaluator does. Also the current + # implementation, borrowed from the coval project, uses scipy, which we would + # want to avoid. (If that's the only issue we can probably work around it.) + def score(self, examples, **kwargs): + """Score a batch of examples.""" + + #TODO traditionally coref uses the average of b_cubed, muc, and ceaf. + # we need to handle the average ourselves. + evaluator = Evaluator(b_cubed) + + for ex in examples: + p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) + g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) + + cluster_info = get_cluster_info(p_clusters, g_clusters) + + evaluator.update(cluster_info) + + scores ={ + "coref_f": evaluator.get_f1(), + "coref_p": evaluator.get_precision(), + "coref_r": evaluator.get_recall(), + } + return scores From 0942a0b51b9d32f2757a96bd28ed6ae7396a9884 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 21 May 2021 18:20:25 +0900 Subject: [PATCH 016/188] Remove coref_er.py The intent of this was that it would be a component pipeline that used entities as input, but that's now covered by the get_mentions function as a pipeline arg. --- spacy/pipeline/coref_er.py | 227 ------------------------------------- 1 file changed, 227 deletions(-) delete mode 100644 spacy/pipeline/coref_er.py diff --git a/spacy/pipeline/coref_er.py b/spacy/pipeline/coref_er.py deleted file mode 100644 index 585bdafddb4..00000000000 --- a/spacy/pipeline/coref_er.py +++ /dev/null @@ -1,227 +0,0 @@ -from typing import Optional, Union, Iterable, Callable, List, Dict, Any -from pathlib import Path -import srsly - -from .pipe import Pipe -from ..scorer import Scorer -from ..training import Example -from ..language import Language -from ..tokens import Doc, Span, SpanGroup -from ..matcher import Matcher -from ..util import ensure_path, to_disk, from_disk, SimpleFrozenList - - -DEFAULT_MENTIONS = "coref_mentions" -DEFAULT_MATCHER_KEY = "POS" -DEFAULT_MATCHER_VALUES = ["PROPN", "PRON"] - - -@Language.factory( - "coref_er", - assigns=[f"doc.spans"], - requires=["doc.ents", "token.ent_iob", "token.ent_type", "token.pos"], - default_config={ - "span_mentions": DEFAULT_MENTIONS, - "matcher_key": DEFAULT_MATCHER_KEY, - "matcher_values": DEFAULT_MATCHER_VALUES, - }, - default_score_weights={ - "coref_mentions_f": None, - "coref_mentions_p": None, - "coref_mentions_r": 1.0, # the mentions data needs to be consistently annotated for precision rates to make sense - }, -) -def make_coref_er(nlp: Language, name: str, span_mentions: str, matcher_key: str, matcher_values: List[str]): - return CorefEntityRecognizer( - nlp, name, span_mentions=span_mentions, matcher_key=matcher_key, matcher_values=matcher_values - ) - - -class CorefEntityRecognizer(Pipe): - """TODO. - - DOCS: https://spacy.io/api/coref_er (TODO) - USAGE: https://spacy.io/usage (TODO) - """ - - def __init__( - self, - nlp: Language, - name: str = "coref_er", - *, - span_mentions: str, - matcher_key: str, - matcher_values: List[str], - ) -> None: - """Initialize the entity recognizer for coreference mentions. TODO - - nlp (Language): The shared nlp object. - name (str): Instance name of the current pipeline component. Typically - passed in automatically from the factory when the component is - added. - span_mentions (str): Key in doc.spans to store the coref mentions in. - matcher_key (List[str]): Field for the matcher to work on (e.g. "POS" or "TAG") - matcher_values (List[str]): Values to match token sequences as - plausible coref mentions - - DOCS: https://spacy.io/api/coref_er#init (TODO) - """ - self.nlp = nlp - self.name = name - self.span_mentions = span_mentions - self.matcher_key = matcher_key - self.matcher_values = matcher_values - self.matcher = Matcher(nlp.vocab) - # TODO: allow to specify any matcher patterns instead? - for value in matcher_values: - self.matcher.add( - f"{value}_SEQ", [[{matcher_key: value, "OP": "+"}]], greedy="LONGEST" - ) - - @staticmethod - def _string_offset(span: Span): - return f"{span.start}-{span.end}" - - def __call__(self, doc: Doc) -> Doc: - """Find relevant coref mentions in the document and add them - to the doc's relevant span container. - - doc (Doc): The Doc object in the pipeline. - RETURNS (Doc): The Doc with added entities, if available. - - DOCS: https://spacy.io/api/coref_er#call (TODO) - """ - error_handler = self.get_error_handler() - try: - # Add NER - spans = list(doc.ents) - offsets = set() - offsets.update([self._string_offset(e) for e in doc.ents]) - - # pronouns and proper nouns - try: - matches = self.matcher(doc, as_spans=True) - except ValueError: - raise ValueError(f"Could not run the matcher for 'coref_er'. If {self.matcher_key} tags " - "are not available, change the 'matcher_key' in the config, " - "or set matcher_values to an empty list.") - spans.extend([m for m in matches if self._string_offset(m) not in offsets]) - offsets.update([self._string_offset(m) for m in matches]) - - # noun_chunks - only if implemented and parsing information is available - try: - spans.extend( - [nc for nc in doc.noun_chunks if self._string_offset(nc) not in offsets] - ) - offsets.update([self._string_offset(nc) for nc in doc.noun_chunks]) - except (NotImplementedError, ValueError): - pass - - self.set_annotations(doc, spans) - return doc - except Exception as e: - error_handler(self.name, self, [doc], e) - - def set_annotations(self, doc, spans): - """Modify the document in place""" - group = SpanGroup(doc, name=self.span_mentions, spans=spans) - if self.span_mentions in doc.spans: - raise ValueError(f"Couldn't store the results of {self.name}, as the key " - f"{self.span_mentions} already exists in 'doc.spans'.") - doc.spans[self.span_mentions] = group - - def initialize( - self, - get_examples: Callable[[], Iterable[Example]], - *, - nlp: Optional[Language] = None, - ): - """Initialize the pipe for training. - - get_examples (Callable[[], Iterable[Example]]): Function that - returns a representative sample of gold-standard Example objects. - nlp (Language): The current nlp object the component is part of. - - DOCS: https://spacy.io/api/coref_er#initialize (TODO) - """ - pass - - def from_bytes( - self, bytes_data: bytes, *, exclude: Iterable[str] = SimpleFrozenList() - ) -> "CorefEntityRecognizer": - """Load the coreference entity recognizer from a bytestring. - - bytes_data (bytes): The bytestring to load. - RETURNS (CorefEntityRecognizer): The loaded coreference entity recognizer. - - DOCS: https://spacy.io/api/coref_er#from_bytes - """ - cfg = srsly.msgpack_loads(bytes_data) - self.span_mentions = cfg.get("span_mentions", DEFAULT_MENTIONS) - self.matcher_key = cfg.get("matcher_key", DEFAULT_MATCHER_KEY) - self.matcher_values = cfg.get("matcher_values", DEFAULT_MATCHER_VALUES) - return self - - def to_bytes(self, *, exclude: Iterable[str] = SimpleFrozenList()) -> bytes: - """Serialize the coreference entity recognizer to a bytestring. - - RETURNS (bytes): The serialized component. - - DOCS: https://spacy.io/api/coref_er#to_bytes (TODO) - """ - serial = {"span_mentions": self.span_mentions} - return srsly.msgpack_dumps(serial) - - def from_disk( - self, path: Union[str, Path], *, exclude: Iterable[str] = SimpleFrozenList() - ) -> "CorefEntityRecognizer": - """Load the coreference entity recognizer from a file. - - path (str / Path): The JSONL file to load. - RETURNS (CorefEntityRecognizer): The loaded coreference entity recognizer . - - DOCS: https://spacy.io/api/coref_er#from_disk (TODO) - """ - path = ensure_path(path) - cfg = {} - deserializers_cfg = {"cfg": lambda p: cfg.update(srsly.read_json(p))} - from_disk(path, deserializers_cfg, {}) - self.span_mentions = cfg.get("span_mentions", DEFAULT_MENTIONS) - self.matcher_key = cfg.get("matcher_key", DEFAULT_MATCHER_KEY) - self.matcher_values = cfg.get("matcher_values", DEFAULT_MATCHER_VALUES) - return self - - def to_disk( - self, path: Union[str, Path], *, exclude: Iterable[str] = SimpleFrozenList() - ) -> None: - """Save the coreference entity recognizer to a directory. - - path (str / Path): The JSONL file to save. - - DOCS: https://spacy.io/api/coref_er#to_disk (TODO) - """ - path = ensure_path(path) - cfg = { - "span_mentions": self.span_mentions, - "matcher_key": self.matcher_key, - "matcher_values": self.matcher_values, - } - serializers = {"cfg": lambda p: srsly.write_json(p, cfg)} - to_disk(path, serializers, {}) - - def score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: - """Score a batch of examples. - - examples (Iterable[Example]): The examples to score. - RETURNS (Dict[str, Any]): The scores, produced by Scorer.score_coref. - - DOCS: https://spacy.io/api/coref_er#score (TODO) - """ - def mentions_getter(doc, span_key): - return doc.spans[span_key] - # This will work better once PR 7209 is merged - kwargs.setdefault("getter", mentions_getter) - kwargs.setdefault("attr", self.span_mentions) - kwargs.setdefault("include_label", False) - kwargs.setdefault("allow_overlap", True) - return Scorer.score_spans(examples, **kwargs) From d6fd5fe1c03d0aac6f99be2db427d410fe3ccb3c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 24 May 2021 14:56:43 +0900 Subject: [PATCH 017/188] Minor cleanup --- spacy/ml/models/coref.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index d4a28c0e425..46e880f6797 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -170,13 +170,15 @@ def span_embeddings_forward( # TODO support attention here tokvecs = xp.concatenate(tokvecs) - spans = [tokvecs[ii:jj] for ii, jj in mentions.tolist()] + spans = [tokvecs[ii:jj] for ii, jj in mentions] avgs = [xp.mean(ss, axis=0) for ss in spans] spanvecs = ops.asarray2f(avgs) # first and last token embeds - starts = [tokvecs[ii] for ii in mentions[:, 0]] - ends = [tokvecs[jj] for jj in mentions[:, 1]] + # XXX probably would be faster to get these at once + #starts = [tokvecs[ii] for ii in mentions[:, 0]] + #ends = [tokvecs[jj] for jj in mentions[:, 1]] + starts, ends = zip(*[(tokvecs[ii], tokvecs[jj]) for ii, jj in mentions]) starts = ops.asarray2f(starts) ends = ops.asarray2f(ends) @@ -366,6 +368,7 @@ def ant_scorer_forward( # make a mask so antecedents precede referrents ant_range = xp.arange(0, cvecs.shape[0]) + # TODO use python warning # with xp.errstate(divide="ignore"): # mask = xp.log( # (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 From d6389b133d42d3024131a2332616f8ad128819be Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 24 May 2021 19:06:15 +0900 Subject: [PATCH 018/188] Don't use a generator for no reason --- spacy/ml/models/coref.py | 6 +----- spacy/pipeline/coref.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 46e880f6797..f3312d5e0ff 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -2,7 +2,7 @@ from thinc.api import Model, Linear, Relu, Dropout, chain, noop from thinc.types import Floats2d, Floats1d, Ints2d, Ragged -from typing import List, Callable, Tuple, Any, Generator +from typing import List, Callable, Tuple, Any from ...tokens import Doc from ...util import registry @@ -70,10 +70,6 @@ def tuplify(layer1: Model, layer2: Model, *layers) -> Model: def tuplify_forward(model, X, is_train): Ys = [] backprops = [] - # If the input is a generator we need to unroll it. - # The type check is necessary because arrays etc. are also OK. - if isinstance(X, Generator): - X = list(X) for layer in model.layers: Y, backprop = layer(X, is_train) Ys.append(Y) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 841f3d7a6e9..a4dba1a5876 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -207,7 +207,7 @@ def update( return losses set_dropout_rate(self.model, drop) - inputs = (example.predicted for example in examples) + inputs = [example.predicted for example in examples] preds, backprop = self.model.begin_update(inputs) score_matrix, mention_idx = preds loss, d_scores = self.get_loss(examples, score_matrix, mention_idx) From a484245f35eead8f39baef4100a2d8d66dbf308d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 24 May 2021 19:08:45 +0900 Subject: [PATCH 019/188] Remove references to coref_er --- spacy/pipeline/__init__.py | 1 - spacy/pipeline/coref.py | 1 - 2 files changed, 2 deletions(-) diff --git a/spacy/pipeline/__init__.py b/spacy/pipeline/__init__.py index 0eecff08f3e..f78261b3e72 100644 --- a/spacy/pipeline/__init__.py +++ b/spacy/pipeline/__init__.py @@ -1,6 +1,5 @@ from .attributeruler import AttributeRuler from .coref import CoreferenceResolver -from .coref_er import CorefEntityRecognizer from .dep_parser import DependencyParser from .entity_linker import EntityLinker from .ner import EntityRecognizer diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index a4dba1a5876..0046190ee33 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -6,7 +6,6 @@ from itertools import islice from .trainable_pipe import TrainablePipe -from .coref_er import DEFAULT_MENTIONS from ..language import Language from ..training import Example, validate_examples, validate_get_examples from ..errors import Errors From 2e3c0e2256226a9defe4780ec054e724a1f5fc2b Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 27 May 2021 13:54:31 +0200 Subject: [PATCH 020/188] delete outdated tests --- spacy/tests/pipeline/test_coref.py | 180 ----------------------------- 1 file changed, 180 deletions(-) delete mode 100644 spacy/tests/pipeline/test_coref.py diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py deleted file mode 100644 index ab3de704492..00000000000 --- a/spacy/tests/pipeline/test_coref.py +++ /dev/null @@ -1,180 +0,0 @@ -import pytest -import spacy -from spacy.matcher import PhraseMatcher -from spacy.training import Example -from spacy.lang.en import English -from spacy.tests.util import make_tempdir -from spacy.tokens import Doc -from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX -from spacy.pipeline.coref_er import DEFAULT_MENTIONS - - -# fmt: off -TRAIN_DATA = [ - ( - "John Smith told Laura that he was running late and asked her whether she could pick up their kids.", - { - "spans": { - DEFAULT_MENTIONS: [ - (0, 10, "MENTION"), - (16, 21, "MENTION"), - (27, 29, "MENTION"), - (57, 60, "MENTION"), - (69, 72, "MENTION"), - (87, 92, "MENTION"), - (87, 97, "MENTION"), - ], - f"{DEFAULT_CLUSTERS_PREFIX}_1": [ - (0, 10, "MENTION"), # John - (27, 29, "MENTION"), - (87, 92, "MENTION"), # 'their' refers to John and Laur - ], - f"{DEFAULT_CLUSTERS_PREFIX}_2": [ - (16, 21, "MENTION"), # Laura - (57, 60, "MENTION"), - (69, 72, "MENTION"), - (87, 92, "MENTION"), # 'their' refers to John and Laura - ], - } - }, - ), - ( - "Yes, I noticed that many friends around me received it. It seems that almost everyone received this SMS.", - { - "spans": { - DEFAULT_MENTIONS: [ - (5, 6, "MENTION"), - (40, 42, "MENTION"), - (52, 54, "MENTION"), - (95, 103, "MENTION"), - ], - f"{DEFAULT_CLUSTERS_PREFIX}_1": [ - (5, 6, "MENTION"), # I - (40, 42, "MENTION"), - - ], - f"{DEFAULT_CLUSTERS_PREFIX}_2": [ - (52, 54, "MENTION"), # SMS - (95, 103, "MENTION"), - ] - } - }, - ), -] -# fmt: on - - -@pytest.fixture -def nlp(): - return English() - - -@pytest.fixture -def examples(nlp): - examples = [] - for text, annot in TRAIN_DATA: - # eg = Example.from_dict(nlp.make_doc(text), annot) - # if PR #7197 is merged, replace below with above line - ref_doc = nlp.make_doc(text) - for key, span_list in annot["spans"].items(): - spans = [] - for span_tuple in span_list: - start_char = span_tuple[0] - end_char = span_tuple[1] - label = span_tuple[2] - span = ref_doc.char_span(start_char, end_char, label=label) - spans.append(span) - ref_doc.spans[key] = spans - eg = Example(nlp.make_doc(text), ref_doc) - examples.append(eg) - return examples - - -def test_coref_er_no_POS(nlp): - doc = nlp("The police woman talked to him.") - coref_er = nlp.add_pipe("coref_er", last=True) - with pytest.raises(ValueError): - coref_er(doc) - - -def test_coref_er_with_POS(nlp): - words = ["The", "police", "woman", "talked", "to", "him", "."] - pos = ["DET", "NOUN", "NOUN", "VERB", "ADP", "PRON", "PUNCT"] - doc = Doc(nlp.vocab, words=words, pos=pos) - coref_er = nlp.add_pipe("coref_er", last=True) - coref_er(doc) - assert len(doc.spans[coref_er.span_mentions]) == 1 - mention = doc.spans[coref_er.span_mentions][0] - assert (mention.text, mention.start, mention.end) == ("him", 5, 6) - - -def test_coref_er_custom_POS(nlp): - words = ["The", "police", "woman", "talked", "to", "him", "."] - pos = ["DET", "NOUN", "NOUN", "VERB", "ADP", "PRON", "PUNCT"] - doc = Doc(nlp.vocab, words=words, pos=pos) - config = {"matcher_key": "POS", "matcher_values": ["NOUN"]} - coref_er = nlp.add_pipe("coref_er", last=True, config=config) - coref_er(doc) - assert len(doc.spans[coref_er.span_mentions]) == 1 - mention = doc.spans[coref_er.span_mentions][0] - assert (mention.text, mention.start, mention.end) == ("police woman", 1, 3) - - -def test_coref_clusters(nlp, examples): - coref_er = nlp.add_pipe("coref_er", last=True) - coref = nlp.add_pipe("coref", last=True) - coref.initialize(lambda: examples) - words = ["Laura", "walked", "her", "dog", "."] - pos = ["PROPN", "VERB", "PRON", "NOUN", "PUNCT"] - doc = Doc(nlp.vocab, words=words, pos=pos) - coref_er(doc) - coref(doc) - assert len(doc.spans[coref_er.span_mentions]) > 0 - found_clusters = 0 - for name, spans in doc.spans.items(): - if name.startswith(coref.span_cluster_prefix): - found_clusters += 1 - assert found_clusters > 0 - - -def test_coref_er_score(nlp, examples): - config = {"matcher_key": "POS", "matcher_values": []} - coref_er = nlp.add_pipe("coref_er", last=True, config=config) - coref = nlp.add_pipe("coref", last=True) - coref.initialize(lambda: examples) - mentions_key = coref_er.span_mentions - cluster_prefix_key = coref.span_cluster_prefix - matcher = PhraseMatcher(nlp.vocab) - terms_1 = ["Laura", "her", "she"] - terms_2 = ["it", "this SMS"] - matcher.add("A", [nlp.make_doc(text) for text in terms_1]) - matcher.add("B", [nlp.make_doc(text) for text in terms_2]) - for eg in examples: - pred = eg.predicted - matches = matcher(pred, as_spans=True) - pred.set_ents(matches) - coref_er(pred) - coref(pred) - eg.predicted = pred - # TODO: if #7209 is merged, experiment with 'include_label' - scores = coref_er.score([eg]) - assert f"{mentions_key}_f" in scores - scores = coref.score([eg]) - assert f"{cluster_prefix_key}_f" in scores - - -def test_coref_serialization(nlp): - # Test that the coref component can be serialized - config_er = {"matcher_key": "TAG", "matcher_values": ["NN"]} - nlp.add_pipe("coref_er", last=True, config=config_er) - nlp.add_pipe("coref", last=True) - assert "coref_er" in nlp.pipe_names - assert "coref" in nlp.pipe_names - - with make_tempdir() as tmp_dir: - nlp.to_disk(tmp_dir) - nlp2 = spacy.load(tmp_dir) - assert "coref_er" in nlp2.pipe_names - assert "coref" in nlp2.pipe_names - coref_er_2 = nlp2.get_pipe("coref_er") - assert coref_er_2.matcher_key == "TAG" From 910026582dbcc986debead0834a688df2f8e7126 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 27 May 2021 16:17:20 +0200 Subject: [PATCH 021/188] set versions to v1 instead of v0 --- spacy/ml/models/coref.py | 2 +- spacy/ml/models/coref_util.py | 2 +- spacy/pipeline/coref.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index f3312d5e0ff..569584ed977 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -17,7 +17,7 @@ ) -@registry.architectures("spacy.Coref.v0") +@registry.architectures("spacy.Coref.v1") def build_coref( tok2vec: Model[List[Doc], List[Floats2d]], get_mentions: Any = get_candidate_mentions, diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 624f89a7093..378e2d0ab68 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -148,7 +148,7 @@ def get_candidate_mentions( return (begins, ends) -@registry.misc("spacy.CorefCandidateGenerator.v0") +@registry.misc("spacy.CorefCandidateGenerator.v1") def create_mention_generator() -> Any: return get_candidate_mentions diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 0046190ee33..a4008e7cc23 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -27,14 +27,14 @@ default_config = """ [model] -@architectures = "spacy.Coref.v0" +@architectures = "spacy.Coref.v1" max_span_width = 20 mention_limit = 3900 dropout = 0.3 hidden = 1000 [model.get_mentions] -@misc = "spacy.CorefCandidateGenerator.v0" +@misc = "spacy.CorefCandidateGenerator.v1" [model.tok2vec] @architectures = "spacy.Tok2Vec.v2" From 04b55bf054f8b658a70014aef1c5a1082200b846 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 27 May 2021 16:31:38 +0200 Subject: [PATCH 022/188] removing unused imports --- spacy/ml/models/coref.py | 16 ++++------------ spacy/ml/models/coref_util.py | 2 ++ spacy/pipeline/coref.py | 1 - 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 569584ed977..c9b0a1b0f3a 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -2,19 +2,11 @@ from thinc.api import Model, Linear, Relu, Dropout, chain, noop from thinc.types import Floats2d, Floats1d, Ints2d, Ragged -from typing import List, Callable, Tuple, Any +from typing import List, Callable, Tuple, Any from ...tokens import Doc from ...util import registry -from .coref_util import ( - get_predicted_clusters, - get_candidate_mentions, - select_non_crossing_spans, - make_clean_doc, - create_gold_scores, - logsumexp, - topk, -) +from .coref_util import get_candidate_mentions, select_non_crossing_spans, topk @registry.architectures("spacy.Coref.v1") @@ -172,8 +164,8 @@ def span_embeddings_forward( # first and last token embeds # XXX probably would be faster to get these at once - #starts = [tokvecs[ii] for ii in mentions[:, 0]] - #ends = [tokvecs[jj] for jj in mentions[:, 1]] + # starts = [tokvecs[ii] for ii in mentions[:, 0]] + # ends = [tokvecs[jj] for jj in mentions[:, 1]] starts, ends = zip(*[(tokvecs[ii], tokvecs[jj]) for ii, jj in mentions]) starts = ops.asarray2f(starts) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 378e2d0ab68..b4ec128dc8f 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -44,6 +44,7 @@ def topk(xp, arr, k, axis=None): def logsumexp(xp, arr, axis=None): """Emulate torch.logsumexp by returning the log of summed exponentials along each row in the given dimension. + TODO: currently not used? Reduces a 2d array to 1d.""" # from slide 5 here: @@ -217,6 +218,7 @@ def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: def make_clean_doc(nlp, doc): """Return a doc with raw data but not span annotations.""" # Surely there is a better way to do this? + # TODO: currently not used? sents = [tok.is_sent_start for tok in doc] words = [tok.text for tok in doc] diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index a4008e7cc23..0fed066db4c 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -17,7 +17,6 @@ create_gold_scores, MentionClusters, get_clusters_from_doc, - logsumexp, get_predicted_clusters, DEFAULT_CLUSTER_PREFIX, doc2clusters, From 391b512afd6a77f7f89c2c931a6e5bb73922636e Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 27 May 2021 16:36:46 +0200 Subject: [PATCH 023/188] fix types of fwd functions --- spacy/ml/models/coref.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index c9b0a1b0f3a..16376bba298 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -133,7 +133,7 @@ def build_span_embedder( def span_embeddings_forward( model, inputs: Tuple[List[Floats2d], List[Doc]], is_train -) -> SpanEmbeddings: +) -> Tuple[SpanEmbeddings, Callable]: ops = model.ops xp = ops.xp @@ -223,7 +223,7 @@ def build_coarse_pruner( def coarse_prune( model, inputs: Tuple[Floats1d, SpanEmbeddings], is_train -) -> SpanEmbeddings: +) -> Tuple[Tuple[Floats1d, SpanEmbeddings], Callable]: """Given scores for mention, output the top non-crossing mentions. Mentions can contain other mentions, but candidate mentions cannot cross each other. @@ -320,7 +320,7 @@ def build_ant_scorer( def ant_scorer_forward( model, inputs: Tuple[Floats1d, SpanEmbeddings], is_train -) -> Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d]: +) -> Tuple[Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d], Callable]: ops = model.ops xp = ops.xp From 0f5c586e2fcd501a695ab0dd7631429f2cac4d38 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 28 May 2021 14:19:55 +0200 Subject: [PATCH 024/188] add basic tests for debugging --- spacy/ml/models/coref.py | 20 ++++-- spacy/tests/pipeline/test_coref.py | 110 +++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 spacy/tests/pipeline/test_coref.py diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 16376bba298..13fdb01fa0a 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -59,6 +59,7 @@ def tuplify(layer1: Model, layer2: Model, *layers) -> Model: ) +# TODO replace this with thinc version once PR is in def tuplify_forward(model, X, is_train): Ys = [] backprops = [] @@ -77,16 +78,27 @@ def backprop_tuplify(dYs): return tuple(Ys), backprop_tuplify -# TODO make more robust, see chain +# TODO replace this with thinc version once PR is in def tuplify_init(model, X, Y) -> Model: if X is None and Y is None: for layer in model.layers: layer.initialize() - + if model.layers[0].has_dim("nI"): + model.set_dim("nI", model.layers[0].get_dim("nI")) return model - for layer in model.layers: - layer.initialize(X=X) + # Try to set nO on each layer, where available. + # All layers have the same input, and the output should map directly from the + # given Y, if provided. + for ii, layer in enumerate(model.layers): + if Y is not None and layer.has_dim("nO") is None: + layer.initialize(X=X, Y=Y[ii]) + else: + layer.initialize(X=X) + + if model.layers[0].has_dim("nI"): + model.set_dim("nI", model.layers[0].get_dim("nI")) + # this model can have an input dimension, but can't have an output dimension return model diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py new file mode 100644 index 00000000000..c7f22cae617 --- /dev/null +++ b/spacy/tests/pipeline/test_coref.py @@ -0,0 +1,110 @@ +import pytest +from spacy import util +from spacy.training import Example +from spacy.lang.en import English +from spacy.tests.util import make_tempdir +from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX + +# fmt: off +TRAIN_DATA = [ + ( + "Yes, I noticed that many friends around me received it. It seems that almost everyone received this SMS.", + { + "spans": { + f"{DEFAULT_CLUSTERS_PREFIX}_1": [ + (5, 6, "MENTION"), # I + (40, 42, "MENTION"), # me + + ], + f"{DEFAULT_CLUSTERS_PREFIX}_2": [ + (52, 54, "MENTION"), # it + (95, 103, "MENTION"), # this SMS + ] + } + }, + ), +] +# fmt: on + + +@pytest.fixture +def nlp(): + return English() + + +def test_add_pipe(nlp): + nlp.add_pipe("coref") + assert nlp.pipe_names == ["coref"] + + +def test_not_initialized(nlp): + nlp.add_pipe("coref") + text = "She gave me her pen." + with pytest.raises(ValueError): + nlp(text) + + +def test_initialized(nlp): + nlp.add_pipe("coref") + nlp.initialize() + assert nlp.pipe_names == ["coref"] + text = "She gave me her pen." + doc = nlp(text) + # The results of this are weird & non-deterministic + print(doc.spans) + + +def test_initialized_2(nlp): + nlp.add_pipe("coref") + nlp.initialize() + assert nlp.pipe_names == ["coref"] + text = "She gave me her pen." + doc = nlp(text) + # TODO: THIS CRASHES + print(nlp(text).spans) + + +def test_overfitting_IO(nlp): + # Simple test to try and quickly overfit the senter - ensuring the ML models work correctly + train_examples = [] + for text, annot in TRAIN_DATA: + train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) + + nlp.add_pipe("coref") + optimizer = nlp.initialize() + test_text = TRAIN_DATA[0][0] + doc = nlp(test_text) + print("BEFORE", doc.spans) + + for i in range(5): + losses = {} + nlp.update(train_examples, sgd=optimizer, losses=losses) + doc = nlp(test_text) + print(i, doc.spans) + print(losses["coref"]) # < 0.001 + + # test the trained model + doc = nlp(test_text) + print("AFTER", doc.spans) + + # Also test the results are still the same after IO + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = util.load_model_from_path(tmp_dir) + doc2 = nlp2(test_text) + print("doc2", doc2.spans) + + # Make sure that running pipe twice, or comparing to call, always amounts to the same predictions + texts = [ + test_text, + "I noticed many friends around me", + "They received it. They received the SMS.", + ] + batch_deps_1 = [doc.spans for doc in nlp.pipe(texts)] + print(batch_deps_1) + batch_deps_2 = [doc.spans for doc in nlp.pipe(texts)] + print(batch_deps_2) + no_batch_deps = [doc.spans for doc in [nlp(text) for text in texts]] + print(no_batch_deps) + # assert_equal(batch_deps_1, batch_deps_2) + # assert_equal(batch_deps_1, no_batch_deps) From 0d81bce9ccd4f1ba524f43edf5a1baafa4d7a154 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 28 May 2021 15:10:35 +0200 Subject: [PATCH 025/188] add failing test for too short a sentence --- spacy/tests/pipeline/test_coref.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index c7f22cae617..550bf70610f 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -50,7 +50,17 @@ def test_initialized(nlp): assert nlp.pipe_names == ["coref"] text = "She gave me her pen." doc = nlp(text) - # The results of this are weird & non-deterministic + # TODO: The results of this are weird & non-deterministic + print(doc.spans) + + +def test_initialized_short(nlp): + nlp.add_pipe("coref") + nlp.initialize() + assert nlp.pipe_names == ["coref"] + text = "Hi there" + # TODO: this crashes with an IndexError: too many indices + doc = nlp(text) print(doc.spans) @@ -59,8 +69,7 @@ def test_initialized_2(nlp): nlp.initialize() assert nlp.pipe_names == ["coref"] text = "She gave me her pen." - doc = nlp(text) - # TODO: THIS CRASHES + # TODO: This crashes though it works when using intermediate var 'doc' ! print(nlp(text).spans) From 0aa1083ce8d4c2b9340281aaa5432a767d207b44 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 28 May 2021 16:52:51 +0200 Subject: [PATCH 026/188] avoid repetitive entities in the output --- spacy/ml/models/coref.py | 2 +- spacy/ml/models/coref_util.py | 12 ++++++------ spacy/tests/pipeline/test_coref.py | 31 +++++++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 13fdb01fa0a..d93fa3b52a9 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -379,7 +379,7 @@ def ant_scorer_forward( scores = pw_prod + pw_sum + mask - top_scores, top_scores_idx = topk(xp, scores, ant_limit) + top_scores, top_scores_idx = topk(xp, scores, min(ant_limit, len(scores))) out.append((top_scores, top_scores_idx)) # In the full model these scores can be further refined. In the current diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index b4ec128dc8f..a0a571340bc 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -109,16 +109,15 @@ def get_predicted_clusters( def get_sentence_map(doc: Doc): """For the given span, return a list of sentence indexes.""" - - try: + if doc.is_sentenced: si = 0 out = [] for sent in doc.sents: - for tok in sent: + for _ in sent: out.append(si) si += 1 return out - except ValueError: + else: # If there are no sents then just return dummy values. # Shouldn't happen in general training, but typical in init. return [0] * len(doc) @@ -198,8 +197,9 @@ def select_non_crossing_spans( # sort idxs by order in doc selected = sorted(selected, key=lambda idx: (starts[idx], ends[idx])) - while len(selected) < limit: - selected.append(selected[0]) # this seems a bit weird? + # This was causing many repetitive entities in the output - removed for now + # while len(selected) < limit: + # selected.append(selected[0]) # this seems a bit weird? return selected diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 550bf70610f..7b96c554071 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -1,4 +1,6 @@ import pytest +import spacy + from spacy import util from spacy.training import Example from spacy.lang.en import English @@ -50,8 +52,9 @@ def test_initialized(nlp): assert nlp.pipe_names == ["coref"] text = "She gave me her pen." doc = nlp(text) - # TODO: The results of this are weird & non-deterministic - print(doc.spans) + for k, v in doc.spans.items(): + # Ensure there are no "She, She, She, She, She, ..." problems + assert len(v) <= 15 def test_initialized_short(nlp): @@ -73,6 +76,28 @@ def test_initialized_2(nlp): print(nlp(text).spans) +def test_coref_serialization(nlp): + # Test that the coref component can be serialized + nlp.add_pipe("coref", last=True) + nlp.initialize() + assert nlp.pipe_names == ["coref"] + text = "She gave me her pen." + doc = nlp(text) + spans_result = doc.spans + + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = spacy.load(tmp_dir) + assert nlp2.pipe_names == ["coref"] + doc2 = nlp2(text) + spans_result2 = doc2.spans + print(1, [(k, len(v)) for k, v in spans_result.items()]) + print(2, [(k, len(v)) for k, v in spans_result2.items()]) + for k, v in spans_result.items(): + assert spans_result[k] == spans_result2[k] + # assert spans_result == spans_result2 + + def test_overfitting_IO(nlp): # Simple test to try and quickly overfit the senter - ensuring the ML models work correctly train_examples = [] @@ -90,7 +115,7 @@ def test_overfitting_IO(nlp): nlp.update(train_examples, sgd=optimizer, losses=losses) doc = nlp(test_text) print(i, doc.spans) - print(losses["coref"]) # < 0.001 + print(losses["coref"]) # < 0.001 # test the trained model doc = nlp(test_text) From 4a4ef72191a1441e0850cd77e0252e62b532eda1 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 28 May 2021 15:56:20 +0900 Subject: [PATCH 027/188] Clean up unused functions `make_clean_doc` is not needed and was removed. `logsumexp` may be needed if I misunderstood the loss calculation, so I left it in for now with a note. --- spacy/ml/models/coref_util.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index a0a571340bc..d2d8bdb9131 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -44,11 +44,13 @@ def topk(xp, arr, k, axis=None): def logsumexp(xp, arr, axis=None): """Emulate torch.logsumexp by returning the log of summed exponentials along each row in the given dimension. - TODO: currently not used? Reduces a 2d array to 1d.""" # from slide 5 here: # https://www.slideshare.net/ryokuta/cupy + + # Note: this was added to reproduce loss calculation in coref-hoi. If loss + # can be calculated using another method this is not necessary. hi = arr.max(axis=axis) hi = xp.expand_dims(hi, 1) return hi.squeeze() + xp.log(xp.exp(arr - hi).sum(axis=axis)) @@ -215,17 +217,6 @@ def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: return out -def make_clean_doc(nlp, doc): - """Return a doc with raw data but not span annotations.""" - # Surely there is a better way to do this? - # TODO: currently not used? - - sents = [tok.is_sent_start for tok in doc] - words = [tok.text for tok in doc] - out = Doc(nlp.vocab, words=words, sent_starts=sents) - return out - - def create_gold_scores( ments: Ints2d, clusters: List[List[Tuple[int, int]]] ) -> List[List[bool]]: From 18444fccd91a2ecafd933eeb65940c3d41531d3a Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 3 Jun 2021 22:17:14 +0900 Subject: [PATCH 028/188] Remove old comment --- spacy/ml/models/coref.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index d93fa3b52a9..18d0c58e9ab 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -176,8 +176,6 @@ def span_embeddings_forward( # first and last token embeds # XXX probably would be faster to get these at once - # starts = [tokvecs[ii] for ii in mentions[:, 0]] - # ends = [tokvecs[jj] for jj in mentions[:, 1]] starts, ends = zip(*[(tokvecs[ii], tokvecs[jj]) for ii, jj in mentions]) starts = ops.asarray2f(starts) From 67d9ebc922932203b141c71d35bf3d66bbe5d3d7 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 3 Jun 2021 22:18:14 +0900 Subject: [PATCH 029/188] Transpose before calculating loss --- spacy/pipeline/coref.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 0fed066db4c..d065955c283 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -315,13 +315,11 @@ def get_loss( # do softmax to cscores cscores = ops.softmax(cscores, axis=1) - diff = self.loss.get_grad(cscores, top_gscores) + diff = self.loss.get_grad(cscores.T, top_gscores.T).T diff = diff[:, 1:] gradients.append((diff, cidx)) - # scalar loss - # loss += xp.sum(log_norm - log_marg) - loss += float(self.loss.get_loss(cscores, top_gscores)) + loss += float(self.loss.get_loss(cscores.T, top_gscores.T)) offset += ll return loss, gradients From 7efbc721a15b9fcaabe76bcbb406ca2e4e078489 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 12 Jun 2021 19:29:27 +0900 Subject: [PATCH 030/188] Don't use is_sentenced --- spacy/ml/models/coref_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index d2d8bdb9131..b163d371302 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -111,7 +111,7 @@ def get_predicted_clusters( def get_sentence_map(doc: Doc): """For the given span, return a list of sentence indexes.""" - if doc.is_sentenced: + if doc.has_annotation("SENT_START"): si = 0 out = [] for sent in doc.sents: From e728b0e45d6a492879e59634a3dff595fe41928c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 12 Jun 2021 19:31:35 +0900 Subject: [PATCH 031/188] Silence warning --- spacy/ml/models/coref.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 18d0c58e9ab..d8fd98e4e7b 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +import warnings from thinc.api import Model, Linear, Relu, Dropout, chain, noop from thinc.types import Floats2d, Floats1d, Ints2d, Ragged @@ -366,14 +367,14 @@ def ant_scorer_forward( # make a mask so antecedents precede referrents ant_range = xp.arange(0, cvecs.shape[0]) - # TODO use python warning - # with xp.errstate(divide="ignore"): - # mask = xp.log( - # (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 - # ).astype(float) - mask = xp.log( - (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 - ).astype(float) + + # This will take the log of 0, which causes a warning, but we're doing + # it on purpose so we can just ignore the warning. + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=RuntimeWarning) + mask = xp.log( + (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 + ).astype(float) scores = pw_prod + pw_sum + mask From d71198ed36d0874525df9d3dc440e518b4586073 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 12 Jun 2021 19:48:01 +0900 Subject: [PATCH 032/188] Replace squeeze with flatten At a few points in the code it's normal to get a "2d" array where each row is a single entry. Calling squeeze will make that a proper 1d array... unless it's just one entry, in which case it turns into a 0d scalar. That's not what we want; flatten() provides the desired behavior. --- spacy/ml/models/coref.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index d8fd98e4e7b..16c1fa2a6be 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -240,7 +240,7 @@ def coarse_prune( Mentions can contain other mentions, but candidate mentions cannot cross each other. """ rawscores, spanembeds = inputs - scores = rawscores.squeeze() + scores = rawscores.flatten() mention_limit = model.attrs["mention_limit"] # XXX: Issue here. Don't need docs to find crossing spans, but might for the limits. # In old code the limit can be: @@ -287,7 +287,7 @@ def coarse_prune_backprop( dYscores, dYembeds = dY dXscores = model.ops.alloc1f(idxlen) - dXscores[selected] = dYscores.squeeze() + dXscores[selected] = dYscores.flatten() dXvecs = model.ops.alloc2f(*vecshape) dXvecs[selected] = dYembeds.vectors.data @@ -362,7 +362,7 @@ def ant_scorer_forward( pw_prod, prod_back = pairwise_product(bilinear, dropout, cvecs, is_train) # now calculate the pairwise mention scores - ms = mscores[offset:hi].squeeze() + ms = mscores[offset:hi].flatten() pw_sum, pw_sum_back = pairwise_sum(ops, ms) # make a mask so antecedents precede referrents From 96be7e885850ebe6be4c90526f0efed11d55ee53 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 13 Jun 2021 19:42:24 +0900 Subject: [PATCH 033/188] Change topk to sort descending Shouldn't change correctness but is a little clearer --- spacy/ml/models/coref_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index b163d371302..cc79282d006 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -34,7 +34,7 @@ def topk(xp, arr, k, axis=None): vals = xp.take_along_axis(arr, idxs, axis=1) - sidxs = xp.argsort(vals, axis=1) + sidxs = xp.argsort(-vals, axis=1) # map these idxs back to the original oidxs = xp.take_along_axis(idxs, sidxs, axis=1) svals = xp.take_along_axis(vals, sidxs, axis=1) From 8452d117ef30ccc1300caa69af1705fa0faba938 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 13 Jun 2021 19:42:55 +0900 Subject: [PATCH 034/188] Fix typo, remove old comment --- spacy/ml/models/coref.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 16c1fa2a6be..72398476850 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -176,7 +176,6 @@ def span_embeddings_forward( spanvecs = ops.asarray2f(avgs) # first and last token embeds - # XXX probably would be faster to get these at once starts, ends = zip(*[(tokvecs[ii], tokvecs[jj]) for ii, jj in mentions]) starts = ops.asarray2f(starts) @@ -343,7 +342,7 @@ def ant_scorer_forward( dropout = model.layers[1] # XXX Note on dimensions: This won't work as a ragged because the floats2ds - # are not all the same dimentions. Each floats2d is a square in the size of + # are not all the same dimensions. Each floats2d is a square in the size of # the number of antecedents in the document. Actually, that will have the # same size if antecedents are padded... Needs checking. From cb2364cf834b7bcf713fbda055dd97d114deac9f Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 17 Jun 2021 17:56:00 +0900 Subject: [PATCH 035/188] Fix type of mask The call here was creating a float64 array, which was turning many downstream scores into float64s. Later on these values were assigned to a float32 array in backprop, and numerical underflow caused things to go to zero. That's almost certainly not the only reason things go to zero, but it is incorrect. --- spacy/ml/models/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 72398476850..fd36c84f728 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -373,7 +373,7 @@ def ant_scorer_forward( warnings.filterwarnings('ignore', category=RuntimeWarning) mask = xp.log( (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 - ).astype(float) + ).astype('f') scores = pw_prod + pw_sum + mask From fce804a79f0b768516c054b703210f7b71cb9bbd Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 17 Jun 2021 21:10:46 +0900 Subject: [PATCH 036/188] Minor optimization --- spacy/ml/models/coref_util.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index cc79282d006..7ace3afcf26 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -143,9 +143,12 @@ def get_candidate_mentions( si = sentence_map[tok.i] # sentence index for ii in range(1, max_span_width): ei = tok.i + ii # end index - if ei < len(doc) and sentence_map[ei] == si: - begins.append(tok.i) - ends.append(ei) + + if ei > len(doc) or sentence_map[ei] != si: + continue + + begins.append(tok.i) + ends.append(ei) return (begins, ends) From 848fd102e74b252eb205cd73882dc723c10d0d62 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 17 Jun 2021 21:19:38 +0900 Subject: [PATCH 037/188] Small fix --- spacy/ml/models/coref_util.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 7ace3afcf26..f578f158000 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -143,8 +143,7 @@ def get_candidate_mentions( si = sentence_map[tok.i] # sentence index for ii in range(1, max_span_width): ei = tok.i + ii # end index - - if ei > len(doc) or sentence_map[ei] != si: + if ei >= len(doc) or sentence_map[ei] != si: continue begins.append(tok.i) From a62121e3b489c5e3e4d8aeaa94b25a58558581c9 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 17 Jun 2021 21:21:46 +0900 Subject: [PATCH 038/188] Expose more hyperparameters --- spacy/ml/models/coref.py | 16 +++++++++++++--- spacy/pipeline/coref.py | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index fd36c84f728..86afb028a54 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -17,7 +17,11 @@ def build_coref( hidden: int = 1000, dropout: float = 0.3, mention_limit: int = 3900, + #TODO this needs a better name. It limits the max mentions as a ratio of + # the token count. + mention_limit_ratio: float = 0.4, max_span_width: int = 20, + antecedent_limit: int = 50 ): dim = tok2vec.get_dim("nO") * 3 @@ -42,8 +46,8 @@ def build_coref( (tok2vec & noop()) >> span_embedder >> (ms & noop()) - >> build_coarse_pruner(mention_limit) - >> build_ant_scorer(bilinear, Dropout(dropout)) + >> build_coarse_pruner(mention_limit, mention_limit_ratio) + >> build_ant_scorer(bilinear, Dropout(dropout), antecedent_limit) ) return model @@ -220,12 +224,14 @@ def backprop_span_embed(dY: SpanEmbeddings) -> Tuple[List[Floats2d], List[Doc]]: def build_coarse_pruner( mention_limit: int, + mention_limit_ratio: float, ) -> Model[SpanEmbeddings, SpanEmbeddings]: model = Model( "CoarsePruner", forward=coarse_prune, attrs={ "mention_limit": mention_limit, + "mention_limit_ratio": mention_limit_ratio, }, ) return model @@ -241,6 +247,7 @@ def coarse_prune( rawscores, spanembeds = inputs scores = rawscores.flatten() mention_limit = model.attrs["mention_limit"] + mention_limit_ratio = model.attrs["mention_limit_ratio"] # XXX: Issue here. Don't need docs to find crossing spans, but might for the limits. # In old code the limit can be: # - hard number per doc @@ -258,8 +265,11 @@ def coarse_prune( starts = spanembeds.indices[offset:hi, 0].tolist() ends = spanembeds.indices[offset:hi:, 1].tolist() + # calculate the doc length + doclen = ends[-1] - starts[0] + mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) # csel is a 1d integer list - csel = select_non_crossing_spans(tops, starts, ends, mention_limit) + csel = select_non_crossing_spans(tops, starts, ends, mlimit) # add the offset so these indices are absolute csel = [ii + offset for ii in csel] # this should be constant because short choices are padded diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index d065955c283..4caf0235927 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -29,8 +29,10 @@ @architectures = "spacy.Coref.v1" max_span_width = 20 mention_limit = 3900 +mention_limit_ratio = 0.4 dropout = 0.3 hidden = 1000 +antecedent_limit = 50 [model.get_mentions] @misc = "spacy.CorefCandidateGenerator.v1" From ccf561112a6923025fca11b36343dac1e4c410a6 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 17 Jun 2021 21:22:17 +0900 Subject: [PATCH 039/188] Remove old comments --- spacy/ml/models/coref.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 86afb028a54..840066bc76e 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -413,13 +413,10 @@ def backprop( offset = 0 for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, veclens): - # I'm not undoing the operations in the right order here. dyscore, dyidx = dy # the full score grid is square fullscore = ops.alloc2f(ll, ll) - # cupy has no put_along_axis - # xp.put_along_axis(fullscore, dyidx, dyscore, 1) for ii, (ridx, rscores) in enumerate(zip(dyidx, dyscore)): fullscore[ii][ridx] = rscores From 5c98c4c3b9f8646f19d60bdde103835b2c99f439 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 17 Jun 2021 21:23:00 +0900 Subject: [PATCH 040/188] Probably fix pw prod backprop I think this change is correct, but intuition doesn't really help here... --- spacy/ml/models/coref.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 840066bc76e..6f2408df5b1 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -459,11 +459,12 @@ def pairwise_product(bilinear, dropout, vecs: Floats2d, is_train): # around separately because the closure handles them. source, source_b = bilinear(vecs, is_train) target, target_b = dropout(vecs, is_train) - pw_prod = bilinear.ops.xp.matmul(source, target.T) + pw_prod = source @ target.T def backward(d_prod: Floats2d) -> Floats2d: dS = source_b(d_prod @ target) - dT = target_b(d_prod @ source) + #dT = target_b(d_prod @ source) + dT = target_b( (source.T @ d_prod).T ) dX = dS + dT return dX From 23344857b983d36ef1aa492a395a22d78c8ad04b Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 28 Jun 2021 18:19:43 +0900 Subject: [PATCH 041/188] Remove unused function --- spacy/ml/models/coref_util.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index f578f158000..4725863f7ee 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -41,21 +41,6 @@ def topk(xp, arr, k, axis=None): return svals, oidxs -def logsumexp(xp, arr, axis=None): - """Emulate torch.logsumexp by returning the log of summed exponentials - along each row in the given dimension. - - Reduces a 2d array to 1d.""" - # from slide 5 here: - # https://www.slideshare.net/ryokuta/cupy - - # Note: this was added to reproduce loss calculation in coref-hoi. If loss - # can be calculated using another method this is not necessary. - hi = arr.max(axis=axis) - hi = xp.expand_dims(hi, 1) - return hi.squeeze() + xp.log(xp.exp(arr - hi).sum(axis=axis)) - - # from model.py, refactored to be non-member def get_predicted_antecedents(xp, antecedent_idx, antecedent_scores): """Get the ID of the antecedent for each span. -1 if no antecedent.""" From 4f377d8de8521c78c3fb922b835d307aad60692b Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 28 Jun 2021 18:20:33 +0900 Subject: [PATCH 042/188] Fix bug in crossing span detection --- spacy/ml/models/coref_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 4725863f7ee..56b238c2f3d 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -181,7 +181,7 @@ def select_non_crossing_spans( if end > max_end: start_to_max_end[start] = end min_start = end_to_min_start.get(end, -1) - if start == -1 or start < min_start: + if min_start == -1 or start < min_start: end_to_min_start[end] = start # sort idxs by order in doc From b02df61eb9e874c362567325e0b5cdcbdf4aa1e9 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 28 Jun 2021 18:21:00 +0900 Subject: [PATCH 043/188] Add test for crossing spans This should maybe go elsewhere? --- spacy/tests/pipeline/test_coref.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 7b96c554071..e09d4827dc0 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -6,6 +6,7 @@ from spacy.lang.en import English from spacy.tests.util import make_tempdir from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX +from spacy.ml.models.coref_util import select_non_crossing_spans # fmt: off TRAIN_DATA = [ @@ -142,3 +143,14 @@ def test_overfitting_IO(nlp): print(no_batch_deps) # assert_equal(batch_deps_1, batch_deps_2) # assert_equal(batch_deps_1, no_batch_deps) + +def test_crossing_spans(): + starts = [ 6, 10, 0, 1, 0, 1, 0, 1, 2, 2, 2] + ends = [12, 12, 2, 3, 3, 4, 4, 4, 3, 4, 5] + idxs = list(range(len(starts))) + limit = 5 + + gold = sorted([0 , 1, 2, 4, 6]) + guess = select_non_crossing_spans(idxs, starts, ends, limit) + guess = sorted(guess) + assert gold == guess From 3f66e185927dcae90bbaaf51ce93e19acf0349cd Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 18:32:36 +0900 Subject: [PATCH 044/188] Clean up pw_prod loss This doesn't change the math but makes the transposes slightly easier to understand (maybe?). --- spacy/ml/models/coref.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 6f2408df5b1..2155d489ce3 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -458,14 +458,13 @@ def pairwise_product(bilinear, dropout, vecs: Floats2d, is_train): # A neat side effect of this is that we don't have to pass the backprops # around separately because the closure handles them. source, source_b = bilinear(vecs, is_train) - target, target_b = dropout(vecs, is_train) - pw_prod = source @ target.T + target, target_b = dropout(vecs.T, is_train) + pw_prod = source @ target def backward(d_prod: Floats2d) -> Floats2d: - dS = source_b(d_prod @ target) - #dT = target_b(d_prod @ source) - dT = target_b( (source.T @ d_prod).T ) - dX = dS + dT + dS = source_b(d_prod @ target.T) + dT = target_b(source.T @ d_prod) + dX = dS + dT.T return dX return pw_prod, backward From f2e0e9dc28911912054fc67a293be88eb6520226 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 18:38:48 +0900 Subject: [PATCH 045/188] Move placeholder handling into model code --- spacy/ml/models/coref.py | 10 +++++++++- spacy/pipeline/coref.py | 23 ++++++----------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 2155d489ce3..e77797d4a56 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -387,7 +387,13 @@ def ant_scorer_forward( scores = pw_prod + pw_sum + mask - top_scores, top_scores_idx = topk(xp, scores, min(ant_limit, len(scores))) + top_limit = min(ant_limit, len(scores)) + top_scores, top_scores_idx = topk(xp, scores, top_limit) + # now add the placeholder + placeholder = ops.alloc2f(scores.shape[0], 1) + top_scores = xp.concatenate( (placeholder, top_scores), 1) + top_scores = ops.softmax(top_scores, axis=1) + out.append((top_scores, top_scores_idx)) # In the full model these scores can be further refined. In the current @@ -414,6 +420,8 @@ def backprop( offset = 0 for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, veclens): dyscore, dyidx = dy + # remove the placeholder + dyscore = dyscore[:, 1:] # the full score grid is square fullscore = ops.alloc2f(ll, ll) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 4caf0235927..f0ae62fa959 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -142,10 +142,6 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: starts = idxs[offset:hi, 0] ends = idxs[offset:hi, 1] - # need to add the placeholder - placeholder = self.model.ops.alloc2f(cscores.shape[0], 1) - cscores = xp.concatenate((placeholder, cscores), 1) - predicted = get_predicted_clusters(xp, starts, ends, ant_idxs, cscores) clusters_by_doc.append(predicted) return clusters_by_doc @@ -291,9 +287,8 @@ def get_loss( offset = 0 gradients = [] - loss = 0 + total_loss = 0 for example, (cscores, cidx) in zip(examples, score_matrix): - # assume cids has absolute mention ids ll = cscores.shape[0] hi = offset + ll @@ -310,20 +305,14 @@ def get_loss( # boolean to float top_gscores = ops.asarray2f(top_gscores) - # add the placeholder to cscores - placeholder = self.model.ops.alloc2f(ll, 1) - cscores = xp.concatenate((placeholder, cscores), 1) + grad, loss = self.loss(cscores.T, top_gscores.T) - # do softmax to cscores - cscores = ops.softmax(cscores, axis=1) + gradients.append((grad.T, cidx)) + total_loss += float(loss) - diff = self.loss.get_grad(cscores.T, top_gscores.T).T - diff = diff[:, 1:] - gradients.append((diff, cidx)) + offset = hi - loss += float(self.loss.get_loss(cscores.T, top_gscores.T)) - offset += ll - return loss, gradients + return total_loss, gradients def initialize( self, From d74fa82c80a3ccdd6f78fbf02c824d82e5e7e2e8 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 18:39:25 +0900 Subject: [PATCH 046/188] Fix axis handling in topk In practice this is only ever used with axis=1, so it wasn't causing issues, even though it was wrong. --- spacy/ml/models/coref_util.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 56b238c2f3d..e045ad31bb5 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -26,18 +26,18 @@ def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: return out -def topk(xp, arr, k, axis=None): - """Given and array and a k value, give the top values and idxs for each row.""" +def topk(xp, arr, k, axis=1): + """Given an array and a k value, give the top values and idxs for each row.""" - part = xp.argpartition(arr, -k, axis=1) + part = xp.argpartition(arr, -k, axis=axis) idxs = xp.flip(part)[:, :k] - vals = xp.take_along_axis(arr, idxs, axis=1) + vals = xp.take_along_axis(arr, idxs, axis=axis) - sidxs = xp.argsort(-vals, axis=1) + sidxs = xp.argsort(-vals, axis=axis) # map these idxs back to the original - oidxs = xp.take_along_axis(idxs, sidxs, axis=1) - svals = xp.take_along_axis(vals, sidxs, axis=1) + oidxs = xp.take_along_axis(idxs, sidxs, axis=axis) + svals = xp.take_along_axis(vals, sidxs, axis=axis) return svals, oidxs From 865caedebd040fc1f60e296c45693664e8de7a5b Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 18:40:38 +0900 Subject: [PATCH 047/188] Remove XXX comment Comment wondered if there should be some subtraction to avoid double counting, but it probably doesn't matter because the diagonal is 0. --- spacy/ml/models/coref.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index e77797d4a56..9a3081bd8af 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -455,7 +455,6 @@ def backward(d_pwsum: Floats2d) -> Floats1d: out = ops.alloc1f(dim) for ii in range(dim): out[ii] = d_pwsum[:, ii].sum() + d_pwsum[ii, :].sum() - # XXX maybe subtract d_pwsum[ii,ii] to avoid double counting? return out From 251a5b43ac04d5028504a751ab21490c629b61d8 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 18:41:46 +0900 Subject: [PATCH 048/188] Minor fix in crossing spans code I think this was technically incorrect but harmless. The reason the code here is different than the reference in coref-hoi is that the indices there are such that they get +1 at the end of processing, while the code here handles indices directly. --- spacy/ml/models/coref_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index e045ad31bb5..b0a632bd8d4 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -162,7 +162,7 @@ def select_non_crossing_spans( start, end = starts[idx], ends[idx] cross = False - for ti in range(start, end + 1): + for ti in range(start, end): max_end = start_to_max_end.get(ti, -1) if ti > start and max_end > end: cross = True From 2d3c559dc4bb4bf5d829a748634afdef79e4bd0f Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 18:43:03 +0900 Subject: [PATCH 049/188] On initialize, use just two samples Coref docs are kind of long, and using 10 samples on a smallish GPU can cause OOMs. --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index f0ae62fa959..2f9baaeb4c0 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -333,7 +333,7 @@ def initialize( X = [] Y = [] - for ex in islice(get_examples(), 10): + for ex in islice(get_examples(), 2): X.append(ex.predicted) Y.append(ex.reference) From 5db28ec2fd47276fa2a2f460d10d6ec61760ed6e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 3 Jul 2021 21:13:32 +0900 Subject: [PATCH 050/188] Tweak mention limit calculation The calculation of this in the coref-hoi code is hard to follow. Based on comments and variable names it sounds like it's using the doc length, but it might actually be the number of mentions? Number of mentions should be much larger and seems more correct, but might want to revisit this. --- spacy/ml/models/coref.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 9a3081bd8af..2545f7325ed 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -267,7 +267,9 @@ def coarse_prune( # calculate the doc length doclen = ends[-1] - starts[0] - mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) + # XXX seems to make more sense to use menlen than doclen here? + #mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) + mlimit = min(mention_limit, int(mention_limit_ratio * menlen)) # csel is a 1d integer list csel = select_non_crossing_spans(tops, starts, ends, mlimit) # add the offset so these indices are absolute From 8f66176b2dd1196d90ab7c72b7cca5080ad98314 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 5 Jul 2021 18:17:10 +0900 Subject: [PATCH 051/188] Fix loss? This rewrites the loss to not use the Thinc crossentropy code at all. The main difference here is that the negative predictions are being masked out (= marginalized over), but negative gradient is still being reflected. I'm still not sure this is exactly right but models seem to train reliably now. --- spacy/ml/models/coref.py | 2 +- spacy/pipeline/coref.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 2545f7325ed..33c278b3dea 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -394,7 +394,7 @@ def ant_scorer_forward( # now add the placeholder placeholder = ops.alloc2f(scores.shape[0], 1) top_scores = xp.concatenate( (placeholder, top_scores), 1) - top_scores = ops.softmax(top_scores, axis=1) + #top_scores = ops.softmax(top_scores, axis=1) out.append((top_scores, top_scores_idx)) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 2f9baaeb4c0..f040e663732 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -1,4 +1,5 @@ from typing import Iterable, Tuple, Optional, Dict, Callable, Any, List +import warnings from thinc.types import Floats2d, Ints2d from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy @@ -305,9 +306,15 @@ def get_loss( # boolean to float top_gscores = ops.asarray2f(top_gscores) - grad, loss = self.loss(cscores.T, top_gscores.T) + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=RuntimeWarning) + log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) + log_norm = ops.softmax(cscores, axis=1) + grad = log_norm - log_marg + # XXX might be better to not square this + loss = (grad ** 2).sum() - gradients.append((grad.T, cidx)) + gradients.append((grad, cidx)) total_loss += float(loss) offset = hi From 13bef2ddb67251b547dec6f4c3efd3849c307856 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 5 Jul 2021 21:06:28 +0900 Subject: [PATCH 052/188] Add width prior feature Not necessary for convergence, but in coref-hoi this seems to add a few f1 points. Note that there are two width-related features in coref-hoi. This is a "prior" that is added to mention scores. The other width related feature is appended to the span embedding representation for other layers to reference. --- spacy/ml/models/coref.py | 42 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 33c278b3dea..f8301434487 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,7 +1,7 @@ from dataclasses import dataclass import warnings -from thinc.api import Model, Linear, Relu, Dropout, chain, noop +from thinc.api import Model, Linear, Relu, Dropout, chain, noop, Embed, add from thinc.types import Floats2d, Floats1d, Ints2d, Ragged from typing import List, Callable, Tuple, Any from ...tokens import Doc @@ -27,7 +27,7 @@ def build_coref( span_embedder = build_span_embedder(get_mentions, max_span_width) - with Model.define_operators({">>": chain, "&": tuplify}): + with Model.define_operators({">>": chain, "&": tuplify, "+": add}): mention_scorer = ( Linear(nI=dim, nO=hidden) @@ -37,10 +37,14 @@ def build_coref( ) mention_scorer.initialize() + #TODO make feature_embed_size a param + feature_embed_size = 20 + width_scorer = build_width_scorer(max_span_width, hidden, feature_embed_size) + bilinear = Linear(nI=dim, nO=dim) >> Dropout(dropout) bilinear.initialize() - ms = build_take_vecs() >> mention_scorer + ms = (build_take_vecs() >> mention_scorer) + width_scorer model = ( (tok2vec & noop()) @@ -129,6 +133,38 @@ def __iadd__(self, right): return self +def build_width_scorer(max_span_width, hidden_size, feature_embed_size=20): + span_width_prior = ( + Embed(nV=max_span_width, nO=feature_embed_size) + >> Linear(nI=feature_embed_size, nO=hidden_size) + >> Relu(nI=hidden_size, nO=hidden_size) + >> Dropout() + >> Linear(nI=hidden_size, nO=1) + ) + span_width_prior.initialize() + return Model( + "WidthScorer", + forward=width_score_forward, + layers=[span_width_prior]) + + +def width_score_forward(model, embeds: SpanEmbeddings, is_train) -> Tuple[Floats1d, Callable]: + # calculate widths, subtracting 1 so it's 0-index + w_ffnn = model.layers[0] + idxs = embeds.indices + widths = idxs[:,1] - idxs[:,0] - 1 + wscores, width_b = w_ffnn(widths, is_train) + + lens = embeds.vectors.lengths + + def width_score_backward(d_score: Floats1d) -> SpanEmbeddings: + + dX = width_b(d_score) + vecs = Ragged(dX, lens) + return SpanEmbeddings(idxs, vecs) + + return wscores, width_score_backward + # model converting a Doc/Mention to span embeddings # get_mentions: Callable[Doc, Pairs[int]] def build_span_embedder( From eb5820b5933d60f8c5b854a7717a1db1b4c9cb2c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 5 Jul 2021 21:08:42 +0900 Subject: [PATCH 053/188] Improve take_vecs implementation This pulls out references to needed bits so that other parts (the larger embeddings) can be freed before backprop. --- spacy/ml/models/coref.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index f8301434487..31643d2485d 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -356,9 +356,11 @@ def build_take_vecs() -> Model[SpanEmbeddings, Floats2d]: def take_vecs_forward(model, inputs: SpanEmbeddings, is_train) -> Floats2d: + idxs = inputs.indices + lens = inputs.vectors.lengths def backprop(dY: Floats2d) -> SpanEmbeddings: - vecs = Ragged(dY, inputs.vectors.lengths) - return SpanEmbeddings(inputs.indices, vecs) + vecs = Ragged(dY, lens) + return SpanEmbeddings(idxs, vecs) return inputs.vectors.data, backprop From d0b041aff422e6c785c901bd13ca8b5869262371 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 8 Jul 2021 16:08:36 +0900 Subject: [PATCH 054/188] Switch to using Thinc tuplify The tuplify code here was added to Thinc proper and that's been released, so no need to have it here any more. --- spacy/ml/models/coref.py | 58 ++-------------------------------------- 1 file changed, 2 insertions(+), 56 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 31643d2485d..719750ecb9e 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,7 +1,8 @@ from dataclasses import dataclass import warnings -from thinc.api import Model, Linear, Relu, Dropout, chain, noop, Embed, add +from thinc.api import Model, Linear, Relu, Dropout +from thinc.api import chain, noop, Embed, add, tuplify from thinc.types import Floats2d, Floats1d, Ints2d, Ragged from typing import List, Callable, Tuple, Any from ...tokens import Doc @@ -56,61 +57,6 @@ def build_coref( return model -# TODO replace this with thinc version once PR is in -def tuplify(layer1: Model, layer2: Model, *layers) -> Model: - layers = (layer1, layer2) + layers - names = [layer.name for layer in layers] - return Model( - "tuple(" + ", ".join(names) + ")", - tuplify_forward, - init=tuplify_init, - layers=layers, - ) - - -# TODO replace this with thinc version once PR is in -def tuplify_forward(model, X, is_train): - Ys = [] - backprops = [] - for layer in model.layers: - Y, backprop = layer(X, is_train) - Ys.append(Y) - backprops.append(backprop) - - def backprop_tuplify(dYs): - dXs = [bp(dY) for bp, dY in zip(backprops, dYs)] - dX = dXs[0] - for dx in dXs[1:]: - dX += dx - return dX - - return tuple(Ys), backprop_tuplify - - -# TODO replace this with thinc version once PR is in -def tuplify_init(model, X, Y) -> Model: - if X is None and Y is None: - for layer in model.layers: - layer.initialize() - if model.layers[0].has_dim("nI"): - model.set_dim("nI", model.layers[0].get_dim("nI")) - return model - - # Try to set nO on each layer, where available. - # All layers have the same input, and the output should map directly from the - # given Y, if provided. - for ii, layer in enumerate(model.layers): - if Y is not None and layer.has_dim("nO") is None: - layer.initialize(X=X, Y=Y[ii]) - else: - layer.initialize(X=X) - - if model.layers[0].has_dim("nI"): - model.set_dim("nI", model.layers[0].get_dim("nI")) - # this model can have an input dimension, but can't have an output dimension - return model - - @dataclass class SpanEmbeddings: indices: Ints2d # Array with 2 columns (for start and end index) From f34915c1e811bbb37e1d53e5de4f97ddd483d092 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 10 Jul 2021 18:08:51 +0900 Subject: [PATCH 055/188] Use scatter_add to speed up span embed backprop This was the slowest part of the code, and using scatter_add here probably reduces the runtime by 50%. --- spacy/ml/models/coref.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 719750ecb9e..66039564ee4 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -187,14 +187,10 @@ def backprop_span_embed(dY: SpanEmbeddings) -> Tuple[List[Floats2d], List[Doc]]: out = model.ops.alloc2f(len(indoc), dim) - for ii, (start, end) in enumerate(dY.indices[offset:hi]): - # adjust indexes to align with doc - start -= tokoffset - end -= tokoffset - - out[start] += starts[ii] - out[end] += ends[ii] - out[start:end] += spanvecs[ii] + idxs = dY.indices[offset:hi] - tokoffset + ops.scatter_add(out, idxs[:, 0], starts) + ops.scatter_add(out, idxs[:, 1], ends) + ops.scatter_add(out, idxs.T, spanvecs) oweights.append(out) offset = hi From d7d317a1b5fcd250828db3b486500aa5db580478 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 10 Jul 2021 19:59:08 +0900 Subject: [PATCH 056/188] Clean up span embedding code This is now cleaner and significantly faster. There's still some messy parts in the code (particularly variable names), will get to that later. --- spacy/ml/models/coref.py | 45 ++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 66039564ee4..37f6ff0ff47 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -118,7 +118,10 @@ def build_span_embedder( max_span_width: int = 20, ) -> Model[Tuple[List[Floats2d], List[Doc]], SpanEmbeddings]: - return Model( + with Model.define_operators({">>": chain, "|": concatenate}): + span_reduce = (extract_spans() >> + (reduce_first() | reduce_last() | reduce_mean())) + model = Model( "SpanEmbedding", forward=span_embeddings_forward, attrs={ @@ -127,7 +130,10 @@ def build_span_embedder( # mention generator "max_span_width": max_span_width, }, + layers=[span_reduce], ) + model.set_ref("span_reducer", span_reduce) + return model def span_embeddings_forward( @@ -157,45 +163,26 @@ def span_embeddings_forward( # TODO support attention here tokvecs = xp.concatenate(tokvecs) - spans = [tokvecs[ii:jj] for ii, jj in mentions] - avgs = [xp.mean(ss, axis=0) for ss in spans] - spanvecs = ops.asarray2f(avgs) + tokvecs_r = Ragged(tokvecs, docmenlens) + mentions_r = Ragged(mentions, docmenlens) - # first and last token embeds - starts, ends = zip(*[(tokvecs[ii], tokvecs[jj]) for ii, jj in mentions]) + span_reduce = model.get_ref("span_reducer") + spanvecs, span_reduce_back = span_reduce( (tokvecs_r, mentions_r), is_train) - starts = ops.asarray2f(starts) - ends = ops.asarray2f(ends) - concat = xp.concatenate((starts, ends, spanvecs), 1) - embeds = Ragged(concat, docmenlens) + embeds = Ragged(spanvecs, docmenlens) def backprop_span_embed(dY: SpanEmbeddings) -> Tuple[List[Floats2d], List[Doc]]: oweights = [] - odocs = [] offset = 0 - tokoffset = 0 - for indoc, mlen in zip(docs, dY.vectors.lengths): + for mlen in dY.vectors.lengths: hi = offset + mlen - hitok = tokoffset + len(indoc) - odocs.append(indoc) # no change vecs = dY.vectors.data[offset:hi] - - starts = vecs[:, :dim] - ends = vecs[:, dim : 2 * dim] - spanvecs = vecs[:, 2 * dim :] - - out = model.ops.alloc2f(len(indoc), dim) - - idxs = dY.indices[offset:hi] - tokoffset - ops.scatter_add(out, idxs[:, 0], starts) - ops.scatter_add(out, idxs[:, 1], ends) - ops.scatter_add(out, idxs.T, spanvecs) - oweights.append(out) + out, out_idx = span_reduce_back(vecs) + oweights.append(out.data) offset = hi - tokoffset = hitok - return oweights, odocs + return oweights, docs return SpanEmbeddings(mentions, embeds), backprop_span_embed From e00bd422d9bb2cd4dbf1db04d048348b62e8eceb Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 10 Jul 2021 20:44:20 +0900 Subject: [PATCH 057/188] Fix span embeds Some of the lengths and backprop weren't right. Also various cleanup. --- spacy/ml/models/coref.py | 23 +++++++++++------------ spacy/ml/models/spancat.py | 2 +- spacy/pipeline/coref.py | 5 +---- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 37f6ff0ff47..5d2dc9ffb16 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -2,7 +2,8 @@ import warnings from thinc.api import Model, Linear, Relu, Dropout -from thinc.api import chain, noop, Embed, add, tuplify +from thinc.api import chain, noop, Embed, add, tuplify, concatenate +from thinc.api import reduce_first, reduce_last, reduce_mean from thinc.types import Floats2d, Floats1d, Ints2d, Ragged from typing import List, Callable, Tuple, Any from ...tokens import Doc @@ -163,7 +164,8 @@ def span_embeddings_forward( # TODO support attention here tokvecs = xp.concatenate(tokvecs) - tokvecs_r = Ragged(tokvecs, docmenlens) + doclens = [len(doc) for doc in docs] + tokvecs_r = Ragged(tokvecs, doclens) mentions_r = Ragged(mentions, docmenlens) span_reduce = model.get_ref("span_reducer") @@ -172,16 +174,15 @@ def span_embeddings_forward( embeds = Ragged(spanvecs, docmenlens) def backprop_span_embed(dY: SpanEmbeddings) -> Tuple[List[Floats2d], List[Doc]]: + grad, idxes = span_reduce_back(dY.vectors.data) oweights = [] offset = 0 - for mlen in dY.vectors.lengths: - hi = offset + mlen - vecs = dY.vectors.data[offset:hi] - out, out_idx = span_reduce_back(vecs) - oweights.append(out.data) - + for doclen in doclens: + hi = offset + doclen + oweights.append(grad.data[offset:hi]) offset = hi + return oweights, docs return SpanEmbeddings(mentions, embeds), backprop_span_embed @@ -420,10 +421,8 @@ def pairwise_sum(ops, mention_scores: Floats1d) -> Tuple[Floats2d, Callable]: def backward(d_pwsum: Floats2d) -> Floats1d: # For the backward pass, the gradient is distributed over the whole row and # column, so pull it all in. - dim = d_pwsum.shape[0] - out = ops.alloc1f(dim) - for ii in range(dim): - out[ii] = d_pwsum[:, ii].sum() + d_pwsum[ii, :].sum() + + out = d_pwsum.sum(axis=0) + d_pwsum.sum(axis=1) return out diff --git a/spacy/ml/models/spancat.py b/spacy/ml/models/spancat.py index 5c49fef40cf..b3fd7bd9849 100644 --- a/spacy/ml/models/spancat.py +++ b/spacy/ml/models/spancat.py @@ -25,7 +25,7 @@ def build_mean_max_reducer(hidden_size: int) -> Model[Ragged, Floats2d]: return chain( concatenate(reduce_last(), reduce_first(), reduce_mean(), reduce_max()), Maxout(nO=hidden_size, normalize=True, dropout=0.0), - ) + ) @registry.architectures.register("spacy.SpanCategorizer.v1") diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index f040e663732..3fa59ab723c 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -296,7 +296,7 @@ def get_loss( clusters = get_clusters_from_doc(example.reference) gscores = create_gold_scores(mention_idx[offset:hi], clusters) - gscores = xp.asarray(gscores) + gscores = ops.asarray2f(gscores) top_gscores = xp.take_along_axis(gscores, cidx, axis=1) # now add the placeholder gold_placeholder = ~top_gscores.any(axis=1).T @@ -311,9 +311,6 @@ def get_loss( log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) log_norm = ops.softmax(cscores, axis=1) grad = log_norm - log_marg - # XXX might be better to not square this - loss = (grad ** 2).sum() - gradients.append((grad, cidx)) total_loss += float(loss) From c25ec292a9bca57274202787e46d893166e5c90d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 10 Jul 2021 22:42:55 +0900 Subject: [PATCH 058/188] Cleanup --- spacy/ml/models/coref.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 5d2dc9ffb16..52a5495099b 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -89,15 +89,17 @@ def build_width_scorer(max_span_width, hidden_size, feature_embed_size=20): >> Linear(nI=hidden_size, nO=1) ) span_width_prior.initialize() - return Model( + model = Model( "WidthScorer", forward=width_score_forward, layers=[span_width_prior]) + model.set_ref("width_prior", span_width_prior) + return model def width_score_forward(model, embeds: SpanEmbeddings, is_train) -> Tuple[Floats1d, Callable]: # calculate widths, subtracting 1 so it's 0-index - w_ffnn = model.layers[0] + w_ffnn = model.get_ref("width_prior") idxs = embeds.indices widths = idxs[:,1] - idxs[:,0] - 1 wscores, width_b = w_ffnn(widths, is_train) @@ -227,6 +229,7 @@ def coarse_prune( cscores = scores[offset:hi] # negate it so highest numbers come first + # This is relatively slow but can't be skipped. tops = (model.ops.xp.argsort(-1 * cscores)).tolist() starts = spanembeds.indices[offset:hi, 0].tolist() ends = spanembeds.indices[offset:hi:, 1].tolist() @@ -298,7 +301,7 @@ def backprop(dY: Floats2d) -> SpanEmbeddings: def build_ant_scorer( bilinear, dropout, ant_limit=50 ) -> Model[Tuple[Floats1d, SpanEmbeddings], List[Floats2d]]: - return Model( + model = Model( "AntScorer", forward=ant_scorer_forward, layers=[bilinear, dropout], @@ -306,6 +309,9 @@ def build_ant_scorer( "ant_limit": ant_limit, }, ) + model.set_ref("bilinear", bilinear) + model.set_ref("dropout", dropout) + return model def ant_scorer_forward( @@ -318,13 +324,8 @@ def ant_scorer_forward( # this contains the coarse bilinear in coref-hoi # coarse bilinear is a single layer linear network # TODO make these proper refs - bilinear = model.layers[0] - dropout = model.layers[1] - - # XXX Note on dimensions: This won't work as a ragged because the floats2ds - # are not all the same dimensions. Each floats2d is a square in the size of - # the number of antecedents in the document. Actually, that will have the - # same size if antecedents are padded... Needs checking. + bilinear = model.get_ref("bilinear") + dropout = model.get_ref("dropout") mscores, sembeds = inputs vecs = sembeds.vectors # ragged @@ -362,7 +363,6 @@ def ant_scorer_forward( # now add the placeholder placeholder = ops.alloc2f(scores.shape[0], 1) top_scores = xp.concatenate( (placeholder, top_scores), 1) - #top_scores = ops.softmax(top_scores, axis=1) out.append((top_scores, top_scores_idx)) @@ -389,6 +389,7 @@ def backprop( offset = 0 for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, veclens): + hi = offset + ll dyscore, dyidx = dy # remove the placeholder dyscore = dyscore[:, 1:] @@ -398,10 +399,10 @@ def backprop( for ii, (ridx, rscores) in enumerate(zip(dyidx, dyscore)): fullscore[ii][ridx] = rscores - dXembeds.data[offset : offset + ll] = prod_back(fullscore) - dXscores[offset : offset + ll] = pw_sum_back(fullscore) + dXembeds.data[offset : hi] = prod_back(fullscore) + dXscores[offset : hi] = pw_sum_back(fullscore) - offset += ll + offset = hi # make it fit back into the linear dXscores = xp.expand_dims(dXscores, 1) return (dXscores, SpanEmbeddings(idxes, dXembeds)) From 447c7070e36c9905da1bbc9a15b4e3c0ad0d983c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 10 Jul 2021 22:45:25 +0900 Subject: [PATCH 059/188] Fix loss Accidentally deleted it --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 3fa59ab723c..f2241896e06 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -312,7 +312,7 @@ def get_loss( log_norm = ops.softmax(cscores, axis=1) grad = log_norm - log_marg gradients.append((grad, cidx)) - total_loss += float(loss) + total_loss += float((grad ** 2).sum()) offset = hi From 80a17071d3df09ca433f5e3788a4e87fffc66dc1 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 11 Jul 2021 18:46:39 +0900 Subject: [PATCH 060/188] Remove unused code --- spacy/pipeline/coref.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index f2241896e06..3cc6606dd0f 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -344,27 +344,6 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) - def alt_score(self, examples: Iterable[Example], **kwargs) -> Dict[str, Any]: - """Score a batch of examples. - - examples (Iterable[Example]): The examples to score. - RETURNS (Dict[str, Any]): The scores, produced by Scorer.score_coref. - - DOCS: https://spacy.io/api/coref#score (TODO) - """ - - def clusters_getter(doc, span_key): - return [ - spans for name, spans in doc.spans.items() if name.startswith(span_key) - ] - - validate_examples(examples, "CoreferenceResolver.score") - kwargs.setdefault("getter", clusters_getter) - kwargs.setdefault("attr", self.span_cluster_prefix) - kwargs.setdefault("include_label", False) - return Scorer.score_clusters(examples, **kwargs) - - # TODO consider whether to use this. It's pretty fast, but it'll be slower if # we use all three methods like the original evaluator does. Also the current # implementation, borrowed from the coval project, uses scipy, which we would From f1796e4af7d6f875a0d38126c6bfbfd21aa7a6f1 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 14 Jul 2021 18:19:00 +0900 Subject: [PATCH 061/188] Fix mention list bug There was an off-by-one error in how mentions are generated that would affect mentions at the end of a sentence. This was pretty nasty. --- spacy/ml/models/coref_util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index b0a632bd8d4..74bfbf6f0b0 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -128,7 +128,8 @@ def get_candidate_mentions( si = sentence_map[tok.i] # sentence index for ii in range(1, max_span_width): ei = tok.i + ii # end index - if ei >= len(doc) or sentence_map[ei] != si: + # Note: this matches slice syntax, so the token index is one less + if ei > len(doc) or sentence_map[ei-1] != si: continue begins.append(tok.i) From 3684f7fdfd8ddbd3f6da34b53487a256663099ca Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 14 Jul 2021 18:22:14 +0900 Subject: [PATCH 062/188] Remove comment from fixed test --- spacy/tests/pipeline/test_coref.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index e09d4827dc0..27a9a5b46c0 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -63,7 +63,6 @@ def test_initialized_short(nlp): nlp.initialize() assert nlp.pipe_names == ["coref"] text = "Hi there" - # TODO: this crashes with an IndexError: too many indices doc = nlp(text) print(doc.spans) From 4a9dc00d86679489d7051ed442fa73fd00043b77 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 14 Jul 2021 18:36:18 +0900 Subject: [PATCH 063/188] Use relative indices for mentions Was using batch absolute indices to manage mentions, but extract_spans expects doc-relative ones. --- spacy/ml/models/coref.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 52a5495099b..bcbed888da6 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -153,7 +153,6 @@ def span_embeddings_forward( get_mentions = model.attrs["get_mentions"] max_span_width = model.attrs["max_span_width"] mentions = ops.alloc2i(0, 2) - total_length = 0 docmenlens = [] # number of mentions per doc for doc in docs: @@ -161,8 +160,7 @@ def span_embeddings_forward( docmenlens.append(len(starts)) cments = ops.asarray2i([starts, ends]).transpose() - mentions = xp.concatenate((mentions, cments + total_length)) - total_length += len(doc) + mentions = xp.concatenate( (mentions, cments) ) # TODO support attention here tokvecs = xp.concatenate(tokvecs) From e9626e38c10e4d98c097552d91e0fcfe5195878f Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 14 Jul 2021 18:37:34 +0900 Subject: [PATCH 064/188] Fix serialization test This test was failing not because the thing it was testing wasn't working, but because of the way span equality works. Span equality relies on doc equality, and doc equality is object identity, so spans from different docs will never be equal. --- spacy/tests/pipeline/test_coref.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 27a9a5b46c0..933d17afed4 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -93,8 +93,10 @@ def test_coref_serialization(nlp): spans_result2 = doc2.spans print(1, [(k, len(v)) for k, v in spans_result.items()]) print(2, [(k, len(v)) for k, v in spans_result2.items()]) + # Note: spans do not compare equal because docs are different and docs + # use object identity for equality for k, v in spans_result.items(): - assert spans_result[k] == spans_result2[k] + assert str(spans_result[k]) == str(spans_result2[k]) # assert spans_result == spans_result2 From 9b63cbb775caca77aeb6bc445a49fe0ab16d6baf Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 15 Jul 2021 18:16:53 +0900 Subject: [PATCH 065/188] Add extract spans import --- spacy/ml/models/coref.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index bcbed888da6..ea876d84d46 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -8,6 +8,7 @@ from typing import List, Callable, Tuple, Any from ...tokens import Doc from ...util import registry +from ..extract_spans import extract_spans from .coref_util import get_candidate_mentions, select_non_crossing_spans, topk From a4531be09989e013d613899517388c3a1e0e7901 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 18 Jul 2021 19:15:32 +0900 Subject: [PATCH 066/188] Add simple mention test --- spacy/tests/pipeline/test_coref.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 933d17afed4..ccd54cc79ce 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -6,7 +6,7 @@ from spacy.lang.en import English from spacy.tests.util import make_tempdir from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX -from spacy.ml.models.coref_util import select_non_crossing_spans +from spacy.ml.models.coref_util import select_non_crossing_spans, get_candidate_mentions # fmt: off TRAIN_DATA = [ @@ -155,3 +155,9 @@ def test_crossing_spans(): guess = select_non_crossing_spans(idxs, starts, ends, limit) guess = sorted(guess) assert gold == guess + +def test_mention_generator(nlp): + doc = nlp("I like text.") # four tokens + max_width = 20 + mentions = get_candidate_mentions(doc, max_width) + assert len(mentions[0]) == 10 From bc081c24fa96474a19d73269fdc875b952029198 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 18 Jul 2021 20:13:10 +0900 Subject: [PATCH 067/188] Add full traditional scoring This calculates scores as an average of three metrics. As noted in the code, these metrics all have issues, but we want to use them to match up with prior work. This should be replaced with some simpler default scoring and the scorer here should be moved to an external project to be passed in just for generating the traditional scores. --- spacy/pipeline/coref.py | 52 ++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 3cc6606dd0f..4e67c9f9fea 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -5,6 +5,7 @@ from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy from thinc.api import set_dropout_rate from itertools import islice +from statistics import mean from .trainable_pipe import TrainablePipe from ..language import Language @@ -23,7 +24,7 @@ doc2clusters, ) -from ..coref_scorer import Evaluator, get_cluster_info, b_cubed +from ..coref_scorer import Evaluator, get_cluster_info, b_cubed, muc, ceafe default_config = """ [model] @@ -344,28 +345,35 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) - # TODO consider whether to use this. It's pretty fast, but it'll be slower if - # we use all three methods like the original evaluator does. Also the current - # implementation, borrowed from the coval project, uses scipy, which we would - # want to avoid. (If that's the only issue we can probably work around it.) + # TODO This mirrors the evaluation used in prior work, but we don't want to + # include this in the final release. The metrics all have fundamental + # issues and the current implementation requires scipy. def score(self, examples, **kwargs): """Score a batch of examples.""" - #TODO traditionally coref uses the average of b_cubed, muc, and ceaf. + #NOTE traditionally coref uses the average of b_cubed, muc, and ceaf. # we need to handle the average ourselves. - evaluator = Evaluator(b_cubed) - - for ex in examples: - p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) - g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) - - cluster_info = get_cluster_info(p_clusters, g_clusters) - - evaluator.update(cluster_info) - - scores ={ - "coref_f": evaluator.get_f1(), - "coref_p": evaluator.get_precision(), - "coref_r": evaluator.get_recall(), - } - return scores + scores = [] + for metric in (b_cubed, muc, ceafe): + evaluator = Evaluator(b_cubed) + + for ex in examples: + p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) + g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) + + cluster_info = get_cluster_info(p_clusters, g_clusters) + + evaluator.update(cluster_info) + + score ={ + "coref_f": evaluator.get_f1(), + "coref_p": evaluator.get_precision(), + "coref_r": evaluator.get_recall(), + } + scores.append(score) + + out = {} + for field in ("f", "p", "r"): + fname = f"coref_{field}" + out[fname] = mean([ss[fname] for ss in scores]) + return out From 8bd0474730c5c4d2e25e772e371bc16561e3f431 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 18 Jul 2021 20:20:22 +0900 Subject: [PATCH 068/188] Run black --- spacy/ml/models/coref.py | 42 ++++++++++++++++++----------------- spacy/ml/models/coref_util.py | 2 +- spacy/pipeline/coref.py | 18 ++++++++------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index ea876d84d46..e6cfd17733a 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -20,11 +20,11 @@ def build_coref( hidden: int = 1000, dropout: float = 0.3, mention_limit: int = 3900, - #TODO this needs a better name. It limits the max mentions as a ratio of + # TODO this needs a better name. It limits the max mentions as a ratio of # the token count. mention_limit_ratio: float = 0.4, max_span_width: int = 20, - antecedent_limit: int = 50 + antecedent_limit: int = 50, ): dim = tok2vec.get_dim("nO") * 3 @@ -40,7 +40,7 @@ def build_coref( ) mention_scorer.initialize() - #TODO make feature_embed_size a param + # TODO make feature_embed_size a param feature_embed_size = 20 width_scorer = build_width_scorer(max_span_width, hidden, feature_embed_size) @@ -90,19 +90,18 @@ def build_width_scorer(max_span_width, hidden_size, feature_embed_size=20): >> Linear(nI=hidden_size, nO=1) ) span_width_prior.initialize() - model = Model( - "WidthScorer", - forward=width_score_forward, - layers=[span_width_prior]) + model = Model("WidthScorer", forward=width_score_forward, layers=[span_width_prior]) model.set_ref("width_prior", span_width_prior) return model -def width_score_forward(model, embeds: SpanEmbeddings, is_train) -> Tuple[Floats1d, Callable]: +def width_score_forward( + model, embeds: SpanEmbeddings, is_train +) -> Tuple[Floats1d, Callable]: # calculate widths, subtracting 1 so it's 0-index w_ffnn = model.get_ref("width_prior") idxs = embeds.indices - widths = idxs[:,1] - idxs[:,0] - 1 + widths = idxs[:, 1] - idxs[:, 0] - 1 wscores, width_b = w_ffnn(widths, is_train) lens = embeds.vectors.lengths @@ -115,6 +114,7 @@ def width_score_backward(d_score: Floats1d) -> SpanEmbeddings: return wscores, width_score_backward + # model converting a Doc/Mention to span embeddings # get_mentions: Callable[Doc, Pairs[int]] def build_span_embedder( @@ -123,8 +123,9 @@ def build_span_embedder( ) -> Model[Tuple[List[Floats2d], List[Doc]], SpanEmbeddings]: with Model.define_operators({">>": chain, "|": concatenate}): - span_reduce = (extract_spans() >> - (reduce_first() | reduce_last() | reduce_mean())) + span_reduce = extract_spans() >> ( + reduce_first() | reduce_last() | reduce_mean() + ) model = Model( "SpanEmbedding", forward=span_embeddings_forward, @@ -161,7 +162,7 @@ def span_embeddings_forward( docmenlens.append(len(starts)) cments = ops.asarray2i([starts, ends]).transpose() - mentions = xp.concatenate( (mentions, cments) ) + mentions = xp.concatenate((mentions, cments)) # TODO support attention here tokvecs = xp.concatenate(tokvecs) @@ -170,7 +171,7 @@ def span_embeddings_forward( mentions_r = Ragged(mentions, docmenlens) span_reduce = model.get_ref("span_reducer") - spanvecs, span_reduce_back = span_reduce( (tokvecs_r, mentions_r), is_train) + spanvecs, span_reduce_back = span_reduce((tokvecs_r, mentions_r), is_train) embeds = Ragged(spanvecs, docmenlens) @@ -236,7 +237,7 @@ def coarse_prune( # calculate the doc length doclen = ends[-1] - starts[0] # XXX seems to make more sense to use menlen than doclen here? - #mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) + # mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) mlimit = min(mention_limit, int(mention_limit_ratio * menlen)) # csel is a 1d integer list csel = select_non_crossing_spans(tops, starts, ends, mlimit) @@ -290,6 +291,7 @@ def build_take_vecs() -> Model[SpanEmbeddings, Floats2d]: def take_vecs_forward(model, inputs: SpanEmbeddings, is_train) -> Floats2d: idxs = inputs.indices lens = inputs.vectors.lengths + def backprop(dY: Floats2d) -> SpanEmbeddings: vecs = Ragged(dY, lens) return SpanEmbeddings(idxs, vecs) @@ -350,10 +352,10 @@ def ant_scorer_forward( # This will take the log of 0, which causes a warning, but we're doing # it on purpose so we can just ignore the warning. with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=RuntimeWarning) + warnings.filterwarnings("ignore", category=RuntimeWarning) mask = xp.log( (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 - ).astype('f') + ).astype("f") scores = pw_prod + pw_sum + mask @@ -361,7 +363,7 @@ def ant_scorer_forward( top_scores, top_scores_idx = topk(xp, scores, top_limit) # now add the placeholder placeholder = ops.alloc2f(scores.shape[0], 1) - top_scores = xp.concatenate( (placeholder, top_scores), 1) + top_scores = xp.concatenate((placeholder, top_scores), 1) out.append((top_scores, top_scores_idx)) @@ -398,8 +400,8 @@ def backprop( for ii, (ridx, rscores) in enumerate(zip(dyidx, dyscore)): fullscore[ii][ridx] = rscores - dXembeds.data[offset : hi] = prod_back(fullscore) - dXscores[offset : hi] = pw_sum_back(fullscore) + dXembeds.data[offset:hi] = prod_back(fullscore) + dXscores[offset:hi] = pw_sum_back(fullscore) offset = hi # make it fit back into the linear @@ -421,7 +423,7 @@ def pairwise_sum(ops, mention_scores: Floats1d) -> Tuple[Floats2d, Callable]: def backward(d_pwsum: Floats2d) -> Floats1d: # For the backward pass, the gradient is distributed over the whole row and # column, so pull it all in. - + out = d_pwsum.sum(axis=0) + d_pwsum.sum(axis=1) return out diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 74bfbf6f0b0..7c77510225e 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -129,7 +129,7 @@ def get_candidate_mentions( for ii in range(1, max_span_width): ei = tok.i + ii # end index # Note: this matches slice syntax, so the token index is one less - if ei > len(doc) or sentence_map[ei-1] != si: + if ei > len(doc) or sentence_map[ei - 1] != si: continue begins.append(tok.i) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 4e67c9f9fea..a703c3a3772 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -79,7 +79,9 @@ def make_coref( ) -> "CoreferenceResolver": """Create a CoreferenceResolver component.""" - return CoreferenceResolver(nlp.vocab, model, name, span_cluster_prefix=span_cluster_prefix) + return CoreferenceResolver( + nlp.vocab, model, name, span_cluster_prefix=span_cluster_prefix + ) class CoreferenceResolver(TrainablePipe): @@ -308,7 +310,7 @@ def get_loss( top_gscores = ops.asarray2f(top_gscores) with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=RuntimeWarning) + warnings.filterwarnings("ignore", category=RuntimeWarning) log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) log_norm = ops.softmax(cscores, axis=1) grad = log_norm - log_marg @@ -351,7 +353,7 @@ def initialize( def score(self, examples, **kwargs): """Score a batch of examples.""" - #NOTE traditionally coref uses the average of b_cubed, muc, and ceaf. + # NOTE traditionally coref uses the average of b_cubed, muc, and ceaf. # we need to handle the average ourselves. scores = [] for metric in (b_cubed, muc, ceafe): @@ -365,11 +367,11 @@ def score(self, examples, **kwargs): evaluator.update(cluster_info) - score ={ - "coref_f": evaluator.get_f1(), - "coref_p": evaluator.get_precision(), - "coref_r": evaluator.get_recall(), - } + score = { + "coref_f": evaluator.get_f1(), + "coref_p": evaluator.get_precision(), + "coref_r": evaluator.get_recall(), + } scores.append(score) out = {} From 3ed0fae671942f6ff1b73bf9e088bf2a48a42457 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 19 Jul 2021 13:00:16 +0900 Subject: [PATCH 069/188] Add multi-sentence mention test Also formatting. --- spacy/tests/pipeline/test_coref.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index ccd54cc79ce..d252cfa8335 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -145,19 +145,30 @@ def test_overfitting_IO(nlp): # assert_equal(batch_deps_1, batch_deps_2) # assert_equal(batch_deps_1, no_batch_deps) + def test_crossing_spans(): - starts = [ 6, 10, 0, 1, 0, 1, 0, 1, 2, 2, 2] - ends = [12, 12, 2, 3, 3, 4, 4, 4, 3, 4, 5] - idxs = list(range(len(starts))) - limit = 5 + starts = [6, 10, 0, 1, 0, 1, 0, 1, 2, 2, 2] + ends = [12, 12, 2, 3, 3, 4, 4, 4, 3, 4, 5] + idxs = list(range(len(starts))) + limit = 5 - gold = sorted([0 , 1, 2, 4, 6]) + gold = sorted([0, 1, 2, 4, 6]) guess = select_non_crossing_spans(idxs, starts, ends, limit) guess = sorted(guess) assert gold == guess -def test_mention_generator(nlp): - doc = nlp("I like text.") # four tokens + +def test_mention_generator(): + # don't use the fixture because we want the sentencizer + nlp = English() + doc = nlp("I like text.") # four tokens max_width = 20 mentions = get_candidate_mentions(doc, max_width) assert len(mentions[0]) == 10 + + # check multiple sentences + nlp.add_pipe("sentencizer") + doc = nlp("I like text. This is text.") # eight tokens, two sents + max_width = 20 + mentions = get_candidate_mentions(doc, max_width) + assert len(mentions[0]) == 20 From a151c62d139d0d6770da49a72b8bc9b09f007d82 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 19 Jul 2021 13:05:26 +0900 Subject: [PATCH 070/188] Add sentence map test --- spacy/tests/pipeline/test_coref.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index d252cfa8335..8a20e43a4dc 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -6,7 +6,11 @@ from spacy.lang.en import English from spacy.tests.util import make_tempdir from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX -from spacy.ml.models.coref_util import select_non_crossing_spans, get_candidate_mentions +from spacy.ml.models.coref_util import ( + select_non_crossing_spans, + get_candidate_mentions, + get_sentence_map, +) # fmt: off TRAIN_DATA = [ @@ -35,6 +39,13 @@ def nlp(): return English() +@pytest.fixture +def snlp(): + en = English() + en.add_pipe("sentencizer") + return en + + def test_add_pipe(nlp): nlp.add_pipe("coref") assert nlp.pipe_names == ["coref"] @@ -158,17 +169,21 @@ def test_crossing_spans(): assert gold == guess -def test_mention_generator(): - # don't use the fixture because we want the sentencizer - nlp = English() +def test_mention_generator(snlp): + nlp = snlp doc = nlp("I like text.") # four tokens max_width = 20 mentions = get_candidate_mentions(doc, max_width) assert len(mentions[0]) == 10 # check multiple sentences - nlp.add_pipe("sentencizer") doc = nlp("I like text. This is text.") # eight tokens, two sents max_width = 20 mentions = get_candidate_mentions(doc, max_width) assert len(mentions[0]) == 20 + + +def test_sentence_map(snlp): + doc = snlp("I like text. This is text.") + sm = get_sentence_map(doc) + assert sm == [0, 0, 0, 0, 1, 1, 1, 1] From 1d1679d4319f75d918548c450ff346e991031bc9 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 21 Jul 2021 19:50:10 +0900 Subject: [PATCH 071/188] Minor speedup This continue should be a break. The current form doesn't cause errors but using a break will be a bit faster. --- spacy/ml/models/coref_util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 7c77510225e..88997f5e302 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -128,9 +128,10 @@ def get_candidate_mentions( si = sentence_map[tok.i] # sentence index for ii in range(1, max_span_width): ei = tok.i + ii # end index + # Note: this matches slice syntax, so the token index is one less if ei > len(doc) or sentence_map[ei - 1] != si: - continue + break begins.append(tok.i) ends.append(ei) From 56803d3909a4c9e56b816da7cf583a6a549baf98 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 8 Aug 2021 19:55:52 +0900 Subject: [PATCH 072/188] Change mention limit to match reference implementations This generall means fewer spans are considered, which makes individual steps in training faster but can make training take longer to find the good spans. --- spacy/ml/models/coref.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index e6cfd17733a..3b14e6ecbcf 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -237,8 +237,8 @@ def coarse_prune( # calculate the doc length doclen = ends[-1] - starts[0] # XXX seems to make more sense to use menlen than doclen here? - # mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) - mlimit = min(mention_limit, int(mention_limit_ratio * menlen)) + # coref-hoi uses doclen (number of words). + mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) # csel is a 1d integer list csel = select_non_crossing_spans(tops, starts, ends, mlimit) # add the offset so these indices are absolute From 00d481dd12c1fc6ed5a9ef865f775159b76ac2c4 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 9 Aug 2021 18:04:42 +0900 Subject: [PATCH 073/188] Stack the mention scorer In the reference implementations, there's usually a function to build a ffnn of arbitrary depth, consisting of a stack of Linear >> Relu >> Dropout. In practice the depth is always 1 in coref-hoi, but in earlier iterations of the model, which are more similar to our model here (since we aren't using attention or even necessarily BERT), using a small depth like 2 was common. This hard-codes a stack of 2. In brief tests this allows similar performance to the unstacked version with much smaller embedding sizes. The depth of the stack could be made into a hyperparameter. --- spacy/ml/models/coref.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 3b14e6ecbcf..511e44476c5 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -36,6 +36,9 @@ def build_coref( Linear(nI=dim, nO=hidden) >> Relu(nI=hidden, nO=hidden) >> Dropout(dropout) + >> Linear(nI=hidden, nO=hidden) + >> Relu(nI=hidden, nO=hidden) + >> Dropout(dropout) >> Linear(nI=hidden, nO=1) ) mention_scorer.initialize() From 230698dc83b512a78df06dd9816e9fd63143a04d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 12 Aug 2021 18:22:08 +0900 Subject: [PATCH 074/188] Fix bug in scorer Scoring code was just using one metric, not all three of interest. --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index a703c3a3772..94677e2bf94 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -357,7 +357,7 @@ def score(self, examples, **kwargs): # we need to handle the average ourselves. scores = [] for metric in (b_cubed, muc, ceafe): - evaluator = Evaluator(b_cubed) + evaluator = Evaluator(metric) for ex in examples: p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) From 0c15ab7ca1dbc897e07c038daa4bbc1f2e0a7076 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 7 Feb 2022 12:17:18 +0100 Subject: [PATCH 075/188] remove irrelevant unit test (behaviour clarified by new error msgs around doc.spans) --- spacy/tests/pipeline/test_coref.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 8a20e43a4dc..61ef6de6f4c 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -78,15 +78,6 @@ def test_initialized_short(nlp): print(doc.spans) -def test_initialized_2(nlp): - nlp.add_pipe("coref") - nlp.initialize() - assert nlp.pipe_names == ["coref"] - text = "She gave me her pen." - # TODO: This crashes though it works when using intermediate var 'doc' ! - print(nlp(text).spans) - - def test_coref_serialization(nlp): # Test that the coref component can be serialized nlp.add_pipe("coref", last=True) From c0cd5025e3336aea457a02453a10ea6dc421422a Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 6 Mar 2022 20:00:15 +0900 Subject: [PATCH 076/188] Start bringin in wl-coref This absolutely does not work. First step here is getting over most of the code in roughly the files we want it in. After the code has been pulled over it can be restructured to match spaCy and cleaned up. --- spacy/ml/models/__init__.py | 2 +- spacy/ml/models/coref.py | 431 ++++++++++++++++++++++++++++++++++++ spacy/tests/test_models.py | 7 +- 3 files changed, 438 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/__init__.py b/spacy/ml/models/__init__.py index 85497559c5d..608f36393f0 100644 --- a/spacy/ml/models/__init__.py +++ b/spacy/ml/models/__init__.py @@ -1,4 +1,4 @@ -from .coref import * +from .coref import * #noqa from .entity_linker import * # noqa from .multi_task import * # noqa from .parser import * # noqa diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 511e44476c5..2e291aa2b9d 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -448,3 +448,434 @@ def backward(d_prod: Floats2d) -> Floats2d: return dX return pw_prod, backward + + +# XXX here down is wl-coref +from typing import List, Tuple + +import torch + +# TODO rename this to coref_util +import .coref_util_wl as utils + +# TODO rename to plain coref +@registry.architectures("spacy.WLCoref.v1") +def build_wl_coref_model( + #TODO add other hyperparams + tok2vec: Model[List[Doc], List[Floats2d]], + ): + + # TODO change to use passed in values for config + config = utils._load_config("/dev/null") + with Model.define_operators({">>": chain}): + + coref_scorer, span_predictor = configure_pytorch_modules(config) + # TODO chain tok2vec with these models + coref_scorer = PyTorchWrapper( + CorefScorer( + config.device, + config.embedding_size, + config.hidden_size, + config.n_hidden_layers, + config.dropout_rate, + config.rough_k, + config.a_scoring_batch_size + ), + convert_inputs=convert_coref_scorer_inputs, + convert_outputs=convert_coref_scorer_outputs + ) + span_predictor = PyTorchWrapper( + SpanPredictor( + 1024, + config.sp_embedding_size, + config.device + ), + convert_inputs=convert_span_predictor_inputs + ) + # TODO combine models so output is uniform (just one forward pass) + # It may be reasonable to have an option to disable span prediction, + # and just return words as spans. + return coref_scorer + +def convert_coref_scorer_inputs( + model: Model, + X: Floats2d, + is_train: bool +): + word_features = xp2torch(X, requires_grad=False) + return ArgsKwargs(args=(word_features, ), kwargs={}), lambda dX: [] + + +def convert_coref_scorer_outputs( + model: Model, + inputs_outputs, + is_train: bool +): + _, outputs = inputs_outputs + scores, indices = outputs + + def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: + dY_t = xp2torch(dY) + return ArgsKwargs( + args=([scores],), + kwargs={"grad_tensors": [dY_t]}, + ) + + scores_xp = torch2xp(scores) + indices_xp = torch2xp(indices) + return (scores_xp, indices_xp), convert_for_torch_backward + +# TODO This probably belongs in the component, not the model. +def predict_span_clusters(span_predictor: Model, + sent_ids: Ints1d, + words: Floats2d, + clusters: List[Ints1d]): + """ + Predicts span clusters based on the word clusters. + + Args: + doc (Doc): the document data + words (torch.Tensor): [n_words, emb_size] matrix containing + embeddings for each of the words in the text + clusters (List[List[int]]): a list of clusters where each cluster + is a list of word indices + + Returns: + List[List[Span]]: span clusters + """ + if not clusters: + return [] + + xp = span_predictor.ops.xp + heads_ids = xp.asarray(sorted(i for cluster in clusters for i in cluster)) + scores = span_predictor.predict((sent_ids, words, heads_ids)) + starts = scores[:, :, 0].argmax(axis=1).tolist() + ends = (scores[:, :, 1].argmax(axis=1) + 1).tolist() + + head2span = { + head: (start, end) + for head, start, end in zip(heads_ids.tolist(), starts, ends) + } + + return [[head2span[head] for head in cluster] + for cluster in clusters] + +# TODO add docstring for this, maybe move to utils. +# This might belong in the component. +def _clusterize( + model, + scores: Floats2d, + top_indices: Ints2d +): + xp = model.ops.xp + antecedents = scores.argmax(axis=1) - 1 + not_dummy = antecedents >= 0 + coref_span_heads = xp.arange(0, len(scores))[not_dummy] + antecedents = top_indices[coref_span_heads, antecedents[not_dummy]] + n_words = scores.shape[0] + nodes = [GraphNode(i) for i in range(n_words)] + for i, j in zip(coref_span_heads.tolist(), antecedents.tolist()): + nodes[i].link(nodes[j]) + assert nodes[i] is not nodes[j] + + clusters = [] + for node in nodes: + if len(node.links) > 0 and not node.visited: + cluster = [] + stack = [node] + while stack: + current_node = stack.pop() + current_node.visited = True + cluster.append(current_node.id) + stack.extend(link for link in current_node.links if not link.visited) + assert len(cluster) > 1 + clusters.append(sorted(cluster)) + return sorted(clusters) + + +class CorefScorer(torch.nn.Module): + """Combines all coref modules together to find coreferent spans. + + Attributes: + config (coref.config.Config): the model's configuration, + see config.toml for the details + epochs_trained (int): number of epochs the model has been trained for + + Submodules (in the order of their usage in the pipeline): + rough_scorer (RoughScorer) + pw (PairwiseEncoder) + a_scorer (AnaphoricityScorer) + sp (SpanPredictor) + """ + def __init__( + self, + device: str, + dist_emb_size: int, + hidden_size: int, + n_layers: int, + dropout_rate: float, + roughk: int, + batch_size: int + ): + super().__init__() + """ + A newly created model is set to evaluation mode. + + Args: + config_path (str): the path to the toml file with the configuration + section (str): the selected section of the config file + epochs_trained (int): the number of epochs finished + (useful for warm start) + """ + # device, dist_emb_size, hidden_size, n_layers, dropout_rate + self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate).to(device) + bert_emb = 1024 + pair_emb = bert_emb * 3 + self.pw.shape + self.a_scorer = AnaphoricityScorer( + pair_emb, + hidden_size, + n_layers, + dropout_rate + ).to(device) + self.lstm = torch.nn.LSTM( + input_size=bert_emb, + hidden_size=bert_emb, + batch_first=True, + ) + self.dropout = torch.nn.Dropout(dropout_rate) + self.rough_scorer = RoughScorer( + bert_emb, + dropout_rate, + roughk + ).to(device) + self.batch_size = batch_size + + def forward( + self, + word_features: torch.Tensor +) -> Tuple[torch.Tensor, torch.Tensor]: + """ + This is a massive method, but it made sense to me to not split it into + several ones to let one see the data flow. + + Args: + word_features: torch.Tensor containing word encodings + Returns: + coreference scores and top indices + """ + # words [n_words, span_emb] + # cluster_ids [n_words] + word_features = torch.unsqueeze(word_features, dim=0) + words, _ = self.lstm(word_features) + words = words.squeeze() + words = self.dropout(words) + # Obtain bilinear scores and leave only top-k antecedents for each word + # top_rough_scores [n_words, n_ants] + # top_indices [n_words, n_ants] + top_rough_scores, top_indices = self.rough_scorer(words) + # Get pairwise features [n_words, n_ants, n_pw_features] + pw = self.pw(top_indices) + batch_size = self.batch_size + a_scores_lst: List[torch.Tensor] = [] + + for i in range(0, len(words), batch_size): + pw_batch = pw[i:i + batch_size] + words_batch = words[i:i + batch_size] + top_indices_batch = top_indices[i:i + batch_size] + top_rough_scores_batch = top_rough_scores[i:i + batch_size] + + # a_scores_batch [batch_size, n_ants] + a_scores_batch = self.a_scorer( + all_mentions=words, mentions_batch=words_batch, + pw_batch=pw_batch, top_indices_batch=top_indices_batch, + top_rough_scores_batch=top_rough_scores_batch + ) + a_scores_lst.append(a_scores_batch) + + coref_scores = torch.cat(a_scores_lst, dim=0) + return coref_scores, top_indices + + +class AnaphoricityScorer(torch.nn.Module): + """ Calculates anaphoricity scores by passing the inputs into a FFNN """ + def __init__(self, + in_features: int, + hidden_size, + n_hidden_layers, + dropout_rate): + super().__init__() + hidden_size = hidden_size + if not n_hidden_layers: + hidden_size = in_features + layers = [] + for i in range(n_hidden_layers): + layers.extend([torch.nn.Linear(hidden_size if i else in_features, + hidden_size), + torch.nn.LeakyReLU(), + torch.nn.Dropout(dropout_rate)]) + self.hidden = torch.nn.Sequential(*layers) + self.out = torch.nn.Linear(hidden_size, out_features=1) + + def forward(self, *, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + all_mentions: torch.Tensor, + mentions_batch: torch.Tensor, + pw_batch: torch.Tensor, + top_indices_batch: torch.Tensor, + top_rough_scores_batch: torch.Tensor, + ) -> torch.Tensor: + """ Builds a pairwise matrix, scores the pairs and returns the scores. + + Args: + all_mentions (torch.Tensor): [n_mentions, mention_emb] + mentions_batch (torch.Tensor): [batch_size, mention_emb] + pw_batch (torch.Tensor): [batch_size, n_ants, pw_emb] + top_indices_batch (torch.Tensor): [batch_size, n_ants] + top_rough_scores_batch (torch.Tensor): [batch_size, n_ants] + + Returns: + torch.Tensor [batch_size, n_ants + 1] + anaphoricity scores for the pairs + a dummy column + """ + # [batch_size, n_ants, pair_emb] + pair_matrix = self._get_pair_matrix( + all_mentions, mentions_batch, pw_batch, top_indices_batch) + + # [batch_size, n_ants] + scores = top_rough_scores_batch + self._ffnn(pair_matrix) + scores = utils.add_dummy(scores, eps=True) + + return scores + + def _ffnn(self, x: torch.Tensor) -> torch.Tensor: + """ + Calculates anaphoricity scores. + + Args: + x: tensor of shape [batch_size, n_ants, n_features] + + Returns: + tensor of shape [batch_size, n_ants] + """ + x = self.out(self.hidden(x)) + return x.squeeze(2) + + @staticmethod + def _get_pair_matrix(all_mentions: torch.Tensor, + mentions_batch: torch.Tensor, + pw_batch: torch.Tensor, + top_indices_batch: torch.Tensor, + ) -> torch.Tensor: + """ + Builds the matrix used as input for AnaphoricityScorer. + + Args: + all_mentions (torch.Tensor): [n_mentions, mention_emb], + all the valid mentions of the document, + can be on a different device + mentions_batch (torch.Tensor): [batch_size, mention_emb], + the mentions of the current batch, + is expected to be on the current device + pw_batch (torch.Tensor): [batch_size, n_ants, pw_emb], + pairwise features of the current batch, + is expected to be on the current device + top_indices_batch (torch.Tensor): [batch_size, n_ants], + indices of antecedents of each mention + + Returns: + torch.Tensor: [batch_size, n_ants, pair_emb] + """ + emb_size = mentions_batch.shape[1] + n_ants = pw_batch.shape[1] + + a_mentions = mentions_batch.unsqueeze(1).expand(-1, n_ants, emb_size) + b_mentions = all_mentions[top_indices_batch] + similarity = a_mentions * b_mentions + + out = torch.cat((a_mentions, b_mentions, similarity, pw_batch), dim=2) + return out + + + +class RoughScorer(torch.nn.Module): + """ + Is needed to give a roughly estimate of the anaphoricity of two candidates, + only top scoring candidates are considered on later steps to reduce + computational complexity. + """ + def __init__( + self, + features: int, + dropout_rate: float, + rough_k: float + ): + super().__init__() + self.dropout = torch.nn.Dropout(dropout_rate) + self.bilinear = torch.nn.Linear(features, features) + + self.k = rough_k + + def forward( + self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + mentions: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Returns rough anaphoricity scores for candidates, which consist of + the bilinear output of the current model summed with mention scores. + """ + # [n_mentions, n_mentions] + pair_mask = torch.arange(mentions.shape[0]) + pair_mask = pair_mask.unsqueeze(1) - pair_mask.unsqueeze(0) + pair_mask = torch.log((pair_mask > 0).to(torch.float)) + pair_mask = pair_mask.to(mentions.device) + bilinear_scores = self.dropout(self.bilinear(mentions)).mm(mentions.T) + rough_scores = pair_mask + bilinear_scores + + return self._prune(rough_scores) + + def _prune(self, + rough_scores: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Selects top-k rough antecedent scores for each mention. + + Args: + rough_scores: tensor of shape [n_mentions, n_mentions], containing + rough antecedent scores of each mention-antecedent pair. + + Returns: + FloatTensor of shape [n_mentions, k], top rough scores + LongTensor of shape [n_mentions, k], top indices + """ + top_scores, indices = torch.topk(rough_scores, + k=min(self.k, len(rough_scores)), + dim=1, sorted=False) + return top_scores, indices + + +class DistancePairwiseEncoder(torch.nn.Module): + + def __init__(self, embedding_size, dropout_rate): + super().__init__() + emb_size = embedding_size + self.distance_emb = torch.nn.Embedding(9, emb_size) + self.dropout = torch.nn.Dropout(dropout_rate) + self.shape = emb_size + + @property + def device(self) -> torch.device: + """ A workaround to get current device (which is assumed to be the + device of the first parameter of one of the submodules) """ + return next(self.distance_emb.parameters()).device + + + def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + top_indices: torch.Tensor + ) -> torch.Tensor: + word_ids = torch.arange(0, top_indices.size(0), device=self.device) + distance = (word_ids.unsqueeze(1) - word_ids[top_indices] + ).clamp_min_(min=1) + log_distance = distance.to(torch.float).log2().floor_() + log_distance = log_distance.clamp_max_(max=6).to(torch.long) + distance = torch.where(distance < 5, distance - 1, log_distance + 2) + distance = self.distance_emb(distance) + return self.dropout(distance) diff --git a/spacy/tests/test_models.py b/spacy/tests/test_models.py index 2306cabb752..ce074fe4213 100644 --- a/spacy/tests/test_models.py +++ b/spacy/tests/test_models.py @@ -6,7 +6,7 @@ import numpy from spacy.ml.models import build_Tok2Vec_model, MultiHashEmbed, MaxoutWindowEncoder from spacy.ml.models import build_bow_text_classifier, build_simple_cnn_text_classifier -from spacy.ml.models import build_spancat_model +from spacy.ml.models import build_spancat_model, build_wl_coref_model from spacy.ml.staticvectors import StaticVectors from spacy.ml.extract_spans import extract_spans, _get_span_indices from spacy.lang.en import English @@ -269,3 +269,8 @@ def test_spancat_model_forward_backward(nO=5): Y, backprop = model((docs, spans), is_train=True) assert Y.shape == (spans.dataXd.shape[0], nO) backprop(Y) + +#TODO expand this +def test_coref_model_init(): + tok2vec = build_Tok2Vec_model(**get_tok2vec_kwargs()) + model = build_wl_coref_model(tok2vec) From 1c697b40116ddb5276450bf0dc4e6b870f06205d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 8 Mar 2022 18:13:09 +0900 Subject: [PATCH 077/188] Remove references to config Replaced with model arguments --- spacy/ml/models/coref.py | 43 ++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 2e291aa2b9d..bfaa9706023 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -461,34 +461,43 @@ def backward(d_prod: Floats2d) -> Floats2d: # TODO rename to plain coref @registry.architectures("spacy.WLCoref.v1") def build_wl_coref_model( - #TODO add other hyperparams tok2vec: Model[List[Doc], List[Floats2d]], + embedding_size: int = 20, + hidden_size: int = 1024, + n_hidden_layers: int = 1, # TODO rename to "depth"? + dropout: float = 0.3, + # pairs to keep per mention after rough scoring + # TODO change to meaningful name + rough_k: int = 50, + # TODO is this not a training loop setting? + a_scoring_batch_size: int = 512, + # span predictor embeddings + sp_embedding_size: int = 64, ): - # TODO change to use passed in values for config - config = utils._load_config("/dev/null") with Model.define_operators({">>": chain}): - - coref_scorer, span_predictor = configure_pytorch_modules(config) # TODO chain tok2vec with these models + # TODO fix device - should be automatic + device = "gpu:0" coref_scorer = PyTorchWrapper( CorefScorer( - config.device, - config.embedding_size, - config.hidden_size, - config.n_hidden_layers, - config.dropout_rate, - config.rough_k, - config.a_scoring_batch_size + device, + embedding_size, + hidden_size, + n_hidden_layers, + dropout_rate, + rough_k, + a_scoring_batch_size ), convert_inputs=convert_coref_scorer_inputs, convert_outputs=convert_coref_scorer_outputs ) span_predictor = PyTorchWrapper( SpanPredictor( - 1024, - config.sp_embedding_size, - config.device + # TODO this was hardcoded to 1024, check + hidden_size, + sp_embedding_size, + device ), convert_inputs=convert_span_predictor_inputs ) @@ -597,8 +606,6 @@ class CorefScorer(torch.nn.Module): """Combines all coref modules together to find coreferent spans. Attributes: - config (coref.config.Config): the model's configuration, - see config.toml for the details epochs_trained (int): number of epochs the model has been trained for Submodules (in the order of their usage in the pipeline): @@ -622,8 +629,6 @@ def __init__( A newly created model is set to evaluation mode. Args: - config_path (str): the path to the toml file with the configuration - section (str): the selected section of the config file epochs_trained (int): the number of epochs finished (useful for warm start) """ From 35cc2b138f810d9a35742d0c3cadc42b1163f76c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 8 Mar 2022 18:13:26 +0900 Subject: [PATCH 078/188] Add span predictor code Accidentally omitted before --- spacy/ml/models/coref.py | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index bfaa9706023..6d12ca85fd3 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -857,6 +857,86 @@ def _prune(self, return top_scores, indices +class SpanPredictor(torch.nn.Module): + def __init__(self, input_size: int, distance_emb_size: int, device): + super().__init__() + self.ffnn = torch.nn.Sequential( + torch.nn.Linear(input_size * 2 + 64, input_size), + torch.nn.ReLU(), + torch.nn.Dropout(0.3), + torch.nn.Linear(input_size, 256), + torch.nn.ReLU(), + torch.nn.Dropout(0.3), + torch.nn.Linear(256, 64), + ) + self.device = device + self.conv = torch.nn.Sequential( + torch.nn.Conv1d(64, 4, 3, 1, 1), + torch.nn.Conv1d(4, 2, 3, 1, 1) + ) + self.emb = torch.nn.Embedding(128, distance_emb_size) # [-63, 63] + too_far + + def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + sent_id, + words: torch.Tensor, + heads_ids: torch.Tensor) -> torch.Tensor: + """ + Calculates span start/end scores of words for each span head in + heads_ids + + Args: + doc (Doc): the document data + words (torch.Tensor): contextual embeddings for each word in the + document, [n_words, emb_size] + heads_ids (torch.Tensor): word indices of span heads + + Returns: + torch.Tensor: span start/end scores, [n_heads, n_words, 2] + """ + # Obtain distance embedding indices, [n_heads, n_words] + relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0], device=words.device).unsqueeze(0)) + # make all valid distances positive + emb_ids = relative_positions + 63 + # "too_far" + emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 + # Obtain "same sentence" boolean mask, [n_heads, n_words] + sent_id = torch.tensor(sent_id, device=words.device) + same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) + + # To save memory, only pass candidates from one sentence for each head + # pair_matrix contains concatenated span_head_emb + candidate_emb + distance_emb + # for each candidate among the words in the same sentence as span_head + # [n_heads, input_size * 2 + distance_emb_size] + rows, cols = same_sent.nonzero(as_tuple=True) + pair_matrix = torch.cat(( + words[heads_ids[rows]], + words[cols], + self.emb(emb_ids[rows, cols]), + ), dim=1) + + lengths = same_sent.sum(dim=1) + padding_mask = torch.arange(0, lengths.max(), device=words.device).unsqueeze(0) + padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] + + # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] + # This is necessary to allow the convolution layer to look at several + # word scores + padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1], device=words.device) + padded_pairs[padding_mask] = pair_matrix + + res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] + res = self.conv(res.permute(0, 2, 1)).permute(0, 2, 1) # [n_heads, n_candidates, 2] + + scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf'), device=words.device) + scores[rows, cols] = res[padding_mask] + + # Make sure that start <= head <= end during inference + if not self.training: + valid_starts = torch.log((relative_positions >= 0).to(torch.float)) + valid_ends = torch.log((relative_positions <= 0).to(torch.float)) + valid_positions = torch.stack((valid_starts, valid_ends), dim=2) + return scores + valid_positions + return scores class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): From c4f9c24738b6d609c0994b285931146ebf282764 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 9 Mar 2022 19:31:11 +0900 Subject: [PATCH 079/188] The coref model is able to be loaded The span predictor component is initialized but not used at all now. Plan is to work on it after the word level clustering part is trainable end-to-end. --- spacy/ml/models/coref.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 6d12ca85fd3..e58afd05b57 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -4,7 +4,8 @@ from thinc.api import Model, Linear, Relu, Dropout from thinc.api import chain, noop, Embed, add, tuplify, concatenate from thinc.api import reduce_first, reduce_last, reduce_mean -from thinc.types import Floats2d, Floats1d, Ints2d, Ragged +from thinc.api import PyTorchWrapper +from thinc.types import Floats2d, Floats1d, Ints1d, Ints2d, Ragged from typing import List, Callable, Tuple, Any from ...tokens import Doc from ...util import registry @@ -456,7 +457,7 @@ def backward(d_prod: Floats2d) -> Floats2d: import torch # TODO rename this to coref_util -import .coref_util_wl as utils +from .coref_util_wl import add_dummy # TODO rename to plain coref @registry.architectures("spacy.WLCoref.v1") @@ -478,20 +479,23 @@ def build_wl_coref_model( with Model.define_operators({">>": chain}): # TODO chain tok2vec with these models # TODO fix device - should be automatic - device = "gpu:0" + device = "cuda:0" coref_scorer = PyTorchWrapper( CorefScorer( device, embedding_size, hidden_size, n_hidden_layers, - dropout_rate, + dropout, rough_k, a_scoring_batch_size ), convert_inputs=convert_coref_scorer_inputs, convert_outputs=convert_coref_scorer_outputs ) + + coref_model = tok2vec >> coref_scorer + # XXX just ignore this until the coref scorer is integrated span_predictor = PyTorchWrapper( SpanPredictor( # TODO this was hardcoded to 1024, check @@ -499,12 +503,13 @@ def build_wl_coref_model( sp_embedding_size, device ), + convert_inputs=convert_span_predictor_inputs ) # TODO combine models so output is uniform (just one forward pass) # It may be reasonable to have an option to disable span prediction, # and just return words as spans. - return coref_scorer + return coref_model def convert_coref_scorer_inputs( model: Model, @@ -534,6 +539,17 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: indices_xp = torch2xp(indices) return (scores_xp, indices_xp), convert_for_torch_backward +def convert_span_predictor_inputs( + model: Model, + X: Tuple[Ints1d, Floats2d, Ints1d], + is_train: bool +): + sent_id = xp2torch(X[0], requires_grad=False) + word_features = xp2torch(X[1], requires_grad=False) + head_ids = xp2torch(X[2], requires_grad=False) + argskwargs = ArgsKwargs(args=(sent_id, word_features, head_ids), kwargs={}) + return argskwargs, lambda dX: [] + # TODO This probably belongs in the component, not the model. def predict_span_clusters(span_predictor: Model, sent_ids: Ints1d, @@ -747,7 +763,7 @@ def forward(self, *, # type: ignore # pylint: disable=arguments-differ #35566 # [batch_size, n_ants] scores = top_rough_scores_batch + self._ffnn(pair_matrix) - scores = utils.add_dummy(scores, eps=True) + scores = add_dummy(scores, eps=True) return scores From d22a00264146f22f69cf6d4780701a34f1a49357 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 14 Mar 2022 17:26:27 +0900 Subject: [PATCH 080/188] Forward/backward pass works Evaluate does not work - predict hasn't been updated --- spacy/ml/models/coref.py | 26 +++++++++++++----- spacy/ml/models/coref_util.py | 19 +++++++++++-- spacy/pipeline/coref.py | 50 ++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index e58afd05b57..bb3c4c43c7e 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -4,7 +4,7 @@ from thinc.api import Model, Linear, Relu, Dropout from thinc.api import chain, noop, Embed, add, tuplify, concatenate from thinc.api import reduce_first, reduce_last, reduce_mean -from thinc.api import PyTorchWrapper +from thinc.api import PyTorchWrapper, ArgsKwargs from thinc.types import Floats2d, Floats1d, Ints1d, Ints2d, Ragged from typing import List, Callable, Tuple, Any from ...tokens import Doc @@ -455,6 +455,7 @@ def backward(d_prod: Floats2d) -> Floats2d: from typing import List, Tuple import torch +from thinc.util import xp2torch, torch2xp # TODO rename this to coref_util from .coref_util_wl import add_dummy @@ -475,6 +476,7 @@ def build_wl_coref_model( # span predictor embeddings sp_embedding_size: int = 64, ): + dim = tok2vec.get_dim("nO") with Model.define_operators({">>": chain}): # TODO chain tok2vec with these models @@ -483,6 +485,7 @@ def build_wl_coref_model( coref_scorer = PyTorchWrapper( CorefScorer( device, + dim, embedding_size, hidden_size, n_hidden_layers, @@ -513,11 +516,20 @@ def build_wl_coref_model( def convert_coref_scorer_inputs( model: Model, - X: Floats2d, + X: List[Floats2d], is_train: bool ): - word_features = xp2torch(X, requires_grad=False) - return ArgsKwargs(args=(word_features, ), kwargs={}), lambda dX: [] + # The input here is List[Floats2d], one for each doc + # just use the first + # TODO real batching + X = X[0] + + word_features = xp2torch(X, requires_grad=is_train) + def backprop(args: ArgsKwargs) -> List[Floats2d]: + # convert to xp and wrap in list + gradients = torch2xp(args.args[0]) + return [gradients] + return ArgsKwargs(args=(word_features, ), kwargs={}), backprop def convert_coref_scorer_outputs( @@ -529,7 +541,7 @@ def convert_coref_scorer_outputs( scores, indices = outputs def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: - dY_t = xp2torch(dY) + dY_t = xp2torch(dY[0]) return ArgsKwargs( args=([scores],), kwargs={"grad_tensors": [dY_t]}, @@ -633,6 +645,7 @@ class CorefScorer(torch.nn.Module): def __init__( self, device: str, + dim: int, # tok2vec size dist_emb_size: int, hidden_size: int, n_layers: int, @@ -650,7 +663,8 @@ def __init__( """ # device, dist_emb_size, hidden_size, n_layers, dropout_rate self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate).to(device) - bert_emb = 1024 + #TODO clean this up + bert_emb = dim pair_emb = bert_emb * 3 + self.pw.shape self.a_scorer = AnaphoricityScorer( pair_emb, diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 88997f5e302..6b4bbc8ba04 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -193,6 +193,11 @@ def select_non_crossing_spans( # selected.append(selected[0]) # this seems a bit weird? return selected +def create_head_span_idxs(ops, doclen: int): + """Helper function to create single-token span indices.""" + aa = ops.xp.arange(0, doclen) + bb = ops.xp.arange(0, doclen) + 1 + return ops.asarray2i([aa, bb]).T def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: """Given a Doc, convert the cluster spans to simple int tuple lists.""" @@ -201,7 +206,13 @@ def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: cluster = [] for span in val: # TODO check that there isn't an off-by-one error here - cluster.append((span.start, span.end)) + #cluster.append((span.start, span.end)) + # TODO This conversion should be happening earlier in processing + head_i = span.root.i + cluster.append( (head_i, head_i + 1) ) + + # don't want duplicates + cluster = list(set(cluster)) out.append(cluster) return out @@ -210,7 +221,11 @@ def create_gold_scores( ments: Ints2d, clusters: List[List[Tuple[int, int]]] ) -> List[List[bool]]: """Given mentions considered for antecedents and gold clusters, - construct a gold score matrix. This does not include the placeholder.""" + construct a gold score matrix. This does not include the placeholder. + + In the gold matrix, the value of a true antecedent is True, and otherwise + it is False. These will be converted to 1/0 values later. + """ # make a mapping of mentions to cluster id # id is not important but equality will be ment2cid = {} diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 94677e2bf94..d8b5349629a 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -18,6 +18,7 @@ from ..ml.models.coref_util import ( create_gold_scores, MentionClusters, + create_head_span_idxs, get_clusters_from_doc, get_predicted_clusters, DEFAULT_CLUSTER_PREFIX, @@ -26,7 +27,8 @@ from ..coref_scorer import Evaluator, get_cluster_info, b_cubed, muc, ceafe -default_config = """ +# TODO remove this - kept for reference for now +old_default_config = """ [model] @architectures = "spacy.Coref.v1" max_span_width = 20 @@ -49,6 +51,35 @@ attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] include_static_vectors = false +[model.tok2vec.encode] +@architectures = "spacy.MaxoutWindowEncoder.v2" +width = ${model.tok2vec.embed.width} +window_size = 1 +maxout_pieces = 3 +depth = 2 +""" + +default_config = """ +[model] +@architectures = "spacy.WLCoref.v1" +embedding_size = 20 +hidden_size = 1024 +n_hidden_layers = 1 +dropout = 0.3 +rough_k = 50 +a_scoring_batch_size = 512 +sp_embedding_size = 64 + +[model.tok2vec] +@architectures = "spacy.Tok2Vec.v2" + +[model.tok2vec.embed] +@architectures = "spacy.MultiHashEmbed.v1" +width = 64 +rows = [2000, 2000, 1000, 1000, 1000, 1000] +attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] +include_static_vectors = false + [model.tok2vec.encode] @architectures = "spacy.MaxoutWindowEncoder.v2" width = ${model.tok2vec.embed.width} @@ -210,7 +241,9 @@ def update( inputs = [example.predicted for example in examples] preds, backprop = self.model.begin_update(inputs) score_matrix, mention_idx = preds + loss, d_scores = self.get_loss(examples, score_matrix, mention_idx) + # TODO check shape here backprop((d_scores, mention_idx)) if sgd is not None: @@ -292,15 +325,24 @@ def get_loss( offset = 0 gradients = [] total_loss = 0 + #TODO change this + # 1. do not handle batching (add it back later) + # 2. don't do index conversion (no mentions, just word indices) + # 3. convert words to spans (if necessary) in gold and predictions + + # massage score matrix to be shaped correctly + score_matrix = [ (score_matrix, None) ] for example, (cscores, cidx) in zip(examples, score_matrix): ll = cscores.shape[0] hi = offset + ll clusters = get_clusters_from_doc(example.reference) - gscores = create_gold_scores(mention_idx[offset:hi], clusters) + span_idxs = create_head_span_idxs(ops, len(example.predicted)) + gscores = create_gold_scores(span_idxs, clusters) gscores = ops.asarray2f(gscores) - top_gscores = xp.take_along_axis(gscores, cidx, axis=1) + #top_gscores = xp.take_along_axis(gscores, cidx, axis=1) + top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) # now add the placeholder gold_placeholder = ~top_gscores.any(axis=1).T gold_placeholder = xp.expand_dims(gold_placeholder, 1) @@ -319,6 +361,8 @@ def get_loss( offset = hi + # Undo the wrapping + gradients = gradients[0][0] return total_loss, gradients def initialize( From 8eadf3781b4772d098ade36ff68a60115e096cdc Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 14 Mar 2022 19:02:17 +0900 Subject: [PATCH 081/188] Training runs now Evaluation needs fixing, and code still needs cleanup. --- spacy/ml/models/coref.py | 1 + spacy/pipeline/coref.py | 17 +++++++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index bb3c4c43c7e..b3664408ec5 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -700,6 +700,7 @@ def forward( """ # words [n_words, span_emb] # cluster_ids [n_words] + self.lstm.flatten_parameters() # XXX without this there's a warning word_features = torch.unsqueeze(word_features, dim=0) words, _ = self.lstm(word_features) words = words.squeeze() diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index d8b5349629a..6833a95b45f 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -155,7 +155,6 @@ def __init__( def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: """Apply the pipeline's model to a batch of docs, without modifying them. - TODO: write actual algorithm docs (Iterable[Doc]): The documents to predict. RETURNS: The models prediction for each document. @@ -165,20 +164,18 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: scores, idxs = self.model.predict(docs) # idxs is a list of mentions (start / end idxs) # each item in scores includes scores and a mapping from scores to mentions + ant_idxs = idxs + #TODO batching xp = self.model.ops.xp - clusters_by_doc = [] - offset = 0 - for cscores, ant_idxs in scores: - ll = cscores.shape[0] - hi = offset + ll + starts = xp.arange(0, len(docs[0])) + ends = xp.arange(0, len(docs[0])) + 1 + + predicted = get_predicted_clusters(xp, starts, ends, ant_idxs, scores) - starts = idxs[offset:hi, 0] - ends = idxs[offset:hi, 1] + clusters_by_doc = [predicted] - predicted = get_predicted_clusters(xp, starts, ends, ant_idxs, cscores) - clusters_by_doc.append(predicted) return clusters_by_doc def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: From dfec6993d65ca3b80aa2f9e48ce4df77d046c4bb Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 14 Mar 2022 19:27:23 +0900 Subject: [PATCH 082/188] Training works now --- spacy/pipeline/coref.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 6833a95b45f..0c42ac94a9d 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -25,6 +25,8 @@ doc2clusters, ) +from ..ml.models.coref_util_wl import make_head_only_clusters + from ..coref_scorer import Evaluator, get_cluster_info, b_cubed, muc, ceafe # TODO remove this - kept for reference for now @@ -235,6 +237,8 @@ def update( return losses set_dropout_rate(self.model, drop) + make_head_only_clusters(examples) + inputs = [example.predicted for example in examples] preds, backprop = self.model.begin_update(inputs) score_matrix, mention_idx = preds @@ -275,6 +279,7 @@ def rehearse( if self._rehearsal_model is None: return losses validate_examples(examples, "CoreferenceResolver.rehearse") + #TODO test this whole function docs = [eg.predicted for eg in examples] if not any(len(doc) for doc in docs): # Handle cases where there are no tokens in any docs. @@ -394,6 +399,7 @@ def initialize( def score(self, examples, **kwargs): """Score a batch of examples.""" + make_head_only_clusters(examples) # NOTE traditionally coref uses the average of b_cubed, muc, and ceaf. # we need to handle the average ourselves. scores = [] From e6917d8dc4e2c7f9c2fc4db1ee6cb018066017b3 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 14 Mar 2022 19:27:55 +0900 Subject: [PATCH 083/188] Add util functions for wl-coref --- spacy/ml/models/coref_util_wl.py | 163 +++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 spacy/ml/models/coref_util_wl.py diff --git a/spacy/ml/models/coref_util_wl.py b/spacy/ml/models/coref_util_wl.py new file mode 100644 index 00000000000..55326c21734 --- /dev/null +++ b/spacy/ml/models/coref_util_wl.py @@ -0,0 +1,163 @@ +""" Contains functions not directly linked to coreference resolution """ + +from typing import List, Set, Dict, Tuple +from thinc.types import Ints1d +from dataclasses import dataclass +from ...tokens import Doc + +import torch + +EPSILON = 1e-7 + +class GraphNode: + def __init__(self, node_id: int): + self.id = node_id + self.links: Set[GraphNode] = set() + self.visited = False + + def link(self, another: "GraphNode"): + self.links.add(another) + another.links.add(self) + + def __repr__(self) -> str: + return str(self.id) + + +def add_dummy(tensor: torch.Tensor, eps: bool = False): + """ Prepends zeros (or a very small value if eps is True) + to the first (not zeroth) dimension of tensor. + """ + kwargs = dict(device=tensor.device, dtype=tensor.dtype) + shape: List[int] = list(tensor.shape) + shape[1] = 1 + if not eps: + dummy = torch.zeros(shape, **kwargs) # type: ignore + else: + dummy = torch.full(shape, EPSILON, **kwargs) # type: ignore + output = torch.cat((dummy, tensor), dim=1) + return output + +def make_head_only_clusters(examples): + """Replace coref clusters with head-only clusters. + + This destructively modifies the docs. + """ + + #TODO what if all clusters are eliminated? + for eg in examples: + final = [] # save out clusters here + for key, sg in eg.reference.spans.items(): + if not key.startswith("coref_clusters_"): + continue + + heads = [span.root.i for span in sg] + heads = list(set(heads)) + head_spans = [eg.reference[hh:hh+1] for hh in heads] + if len(heads) > 1: + final.append(head_spans) + + # now delete the existing clusters + keys = list(eg.reference.spans.keys()) + for key in keys: + if not key.startswith("coref_clusters_"): + continue + + del eg.reference.spans[key] + + # now add the new spangroups + for ii, spans in enumerate(final): + #TODO support alternate keys + eg.reference.spans[f"coref_clusters_{ii}"] = spans + +# TODO replace with spaCy config +@dataclass +class CorefConfig: # pylint: disable=too-many-instance-attributes, too-few-public-methods + """ Contains values needed to set up the coreference model. """ + section: str + + data_dir: str + + train_data: str + dev_data: str + test_data: str + + device: str + + bert_model: str + bert_window_size: int + + embedding_size: int + sp_embedding_size: int + a_scoring_batch_size: int + hidden_size: int + n_hidden_layers: int + + max_span_len: int + + rough_k: int + + bert_finetune: bool + bert_mini_finetune: bool + dropout_rate: float + learning_rate: float + bert_learning_rate: float + train_epochs: int + bce_loss_weight: float + + tokenizer_kwargs: Dict[str, dict] + conll_log_dir: str + + +def get_sent_ids(doc): + sid = 0 + sids = [] + for sent in doc.sents: + for tok in sent: + sids.append(sid) + sid += 1 + return sids + +def get_cluster_ids(doc): + """Get the cluster ids of head tokens.""" + + out = [0] * len(doc) + head_spangroups = [doc.spans[sk] for sk in doc.spans if sk.startswith("coref_word_clusters")] + for ii, group in enumerate(head_spangroups, start=1): + for span in group: + out[span[0].i] = ii + + return out + +def get_head2span(doc): + out = [] + for sk in doc.spans: + if not sk.startswith("coref_clusters"): + continue + + if len(doc.spans[sk]) == 1: + print("===== UNARY MENTION ====") + + for span in doc.spans[sk]: + out.append( (span.root.i, span.start, span.end) ) + return out + + +def doc2tensors( + xp, + doc: Doc +) -> Tuple[Ints1d, Ints1d, Ints1d, Ints1d, Ints1d]: + sent_ids = get_sent_ids(doc) + cluster_ids = get_cluster_ids(doc) + head2span = get_head2span(doc) + + + if not head2span: + heads, starts, ends = [], [], [] + else: + heads, starts, ends = zip(*head2span) + sent_ids = xp.asarray(sent_ids) + cluster_ids = xp.asarray(cluster_ids) + heads = xp.asarray(heads) + starts = xp.asarray(starts) + ends = xp.asarray(ends) - 1 + return sent_ids, cluster_ids, heads, starts, ends From 0522a43116201241a249544b78db338c296b603e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 15 Mar 2022 19:19:15 +0900 Subject: [PATCH 084/188] Make span2head component --- spacy/ml/models/coref_util_wl.py | 31 +------------------------------ spacy/pipeline/coref.py | 30 +++++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/spacy/ml/models/coref_util_wl.py b/spacy/ml/models/coref_util_wl.py index 55326c21734..20a5f40c455 100644 --- a/spacy/ml/models/coref_util_wl.py +++ b/spacy/ml/models/coref_util_wl.py @@ -4,6 +4,7 @@ from thinc.types import Ints1d from dataclasses import dataclass from ...tokens import Doc +from ...language import Language import torch @@ -37,37 +38,7 @@ def add_dummy(tensor: torch.Tensor, eps: bool = False): output = torch.cat((dummy, tensor), dim=1) return output -def make_head_only_clusters(examples): - """Replace coref clusters with head-only clusters. - This destructively modifies the docs. - """ - - #TODO what if all clusters are eliminated? - for eg in examples: - final = [] # save out clusters here - for key, sg in eg.reference.spans.items(): - if not key.startswith("coref_clusters_"): - continue - - heads = [span.root.i for span in sg] - heads = list(set(heads)) - head_spans = [eg.reference[hh:hh+1] for hh in heads] - if len(heads) > 1: - final.append(head_spans) - - # now delete the existing clusters - keys = list(eg.reference.spans.keys()) - for key in keys: - if not key.startswith("coref_clusters_"): - continue - - del eg.reference.spans[key] - - # now add the new spangroups - for ii, spans in enumerate(final): - #TODO support alternate keys - eg.reference.spans[f"coref_clusters_{ii}"] = spans # TODO replace with spaCy config @dataclass diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 0c42ac94a9d..db93051d7fe 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -25,8 +25,6 @@ doc2clusters, ) -from ..ml.models.coref_util_wl import make_head_only_clusters - from ..coref_scorer import Evaluator, get_cluster_info, b_cubed, muc, ceafe # TODO remove this - kept for reference for now @@ -93,6 +91,31 @@ DEFAULT_CLUSTERS_PREFIX = "coref_clusters" +@Language.component("span2head") +def make_head_only_clusters(doc, old_key="coref_clusters", new_key="coref_head_clusters"): + """Create coref head clusters from span clusters. + + The old clusters are left alone, and the new clusters are added under a different key. + """ + final = [] + for key, sg in doc.spans.items(): + if not key.startswith("{old_key}_"): + continue + + heads = [span.root.i for span in sg] + heads = sorted(list(set(heads))) + head_spans = [doc[hh:hh+1] for hh in heads] + #print("===== headifying =====") + #print(sg) + #print(head_spans) + # singletons are skipped + if len(heads) > 1: + final.append(head_spans) + + # now add the new spangroups + for ii, spans in enumerate(final): + doc.spans[f"{new_key}_{ii}"] = spans + return doc @Language.factory( "coref", @@ -237,8 +260,6 @@ def update( return losses set_dropout_rate(self.model, drop) - make_head_only_clusters(examples) - inputs = [example.predicted for example in examples] preds, backprop = self.model.begin_update(inputs) score_matrix, mention_idx = preds @@ -399,7 +420,6 @@ def initialize( def score(self, examples, **kwargs): """Score a batch of examples.""" - make_head_only_clusters(examples) # NOTE traditionally coref uses the average of b_cubed, muc, and ceaf. # we need to handle the average ourselves. scores = [] From 17d017a177bb71a44075cb35b13d7c1543d47600 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 15 Mar 2022 19:52:20 +0900 Subject: [PATCH 085/188] Remove span2head This doesn't work as a component because it needs to modify gold data, so instead it's a conversion script (in another repo). --- spacy/pipeline/coref.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index db93051d7fe..861f2ec5e7a 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -91,32 +91,6 @@ DEFAULT_CLUSTERS_PREFIX = "coref_clusters" -@Language.component("span2head") -def make_head_only_clusters(doc, old_key="coref_clusters", new_key="coref_head_clusters"): - """Create coref head clusters from span clusters. - - The old clusters are left alone, and the new clusters are added under a different key. - """ - final = [] - for key, sg in doc.spans.items(): - if not key.startswith("{old_key}_"): - continue - - heads = [span.root.i for span in sg] - heads = sorted(list(set(heads))) - head_spans = [doc[hh:hh+1] for hh in heads] - #print("===== headifying =====") - #print(sg) - #print(head_spans) - # singletons are skipped - if len(heads) > 1: - final.append(head_spans) - - # now add the new spangroups - for ii, spans in enumerate(final): - doc.spans[f"{new_key}_{ii}"] = spans - return doc - @Language.factory( "coref", assigns=["doc.spans"], From 55039a66ad886545ac6d4cbf94b667ba8fba9eb3 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 15 Mar 2022 19:53:09 +0900 Subject: [PATCH 086/188] Remove old default config --- spacy/pipeline/coref.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 861f2ec5e7a..82a36947372 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -27,37 +27,6 @@ from ..coref_scorer import Evaluator, get_cluster_info, b_cubed, muc, ceafe -# TODO remove this - kept for reference for now -old_default_config = """ -[model] -@architectures = "spacy.Coref.v1" -max_span_width = 20 -mention_limit = 3900 -mention_limit_ratio = 0.4 -dropout = 0.3 -hidden = 1000 -antecedent_limit = 50 - -[model.get_mentions] -@misc = "spacy.CorefCandidateGenerator.v1" - -[model.tok2vec] -@architectures = "spacy.Tok2Vec.v2" - -[model.tok2vec.embed] -@architectures = "spacy.MultiHashEmbed.v1" -width = 64 -rows = [2000, 2000, 1000, 1000, 1000, 1000] -attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] -include_static_vectors = false - -[model.tok2vec.encode] -@architectures = "spacy.MaxoutWindowEncoder.v2" -width = ${model.tok2vec.embed.width} -window_size = 1 -maxout_pieces = 3 -depth = 2 -""" default_config = """ [model] From abdc7d87af5f303a886491b5e2560954ad9988ff Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 15 Mar 2022 19:59:44 +0900 Subject: [PATCH 087/188] Clean up util code Moved everything into coref_util.py, deleted wl-specific file. --- spacy/ml/models/coref.py | 2 +- spacy/ml/models/coref_util.py | 32 +++++++- spacy/ml/models/coref_util_wl.py | 134 ------------------------------- 3 files changed, 32 insertions(+), 136 deletions(-) delete mode 100644 spacy/ml/models/coref_util_wl.py diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index b3664408ec5..139eaca8538 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -458,7 +458,7 @@ def backward(d_prod: Floats2d) -> Floats2d: from thinc.util import xp2torch, torch2xp # TODO rename this to coref_util -from .coref_util_wl import add_dummy +from .coref_util import add_dummy # TODO rename to plain coref @registry.architectures("spacy.WLCoref.v1") diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 6b4bbc8ba04..d45cdc81008 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -1,13 +1,43 @@ from thinc.types import Ints2d from spacy.tokens import Doc -from typing import List, Tuple, Callable, Any +from typing import List, Tuple, Callable, Any, Set, Dict from ...util import registry +import torch # type alias to make writing this less tedious MentionClusters = List[List[Tuple[int, int]]] DEFAULT_CLUSTER_PREFIX = "coref_clusters" +EPSILON = 1e-7 + +class GraphNode: + def __init__(self, node_id: int): + self.id = node_id + self.links: Set[GraphNode] = set() + self.visited = False + + def link(self, another: "GraphNode"): + self.links.add(another) + another.links.add(self) + + def __repr__(self) -> str: + return str(self.id) + + +def add_dummy(tensor: torch.Tensor, eps: bool = False): + """ Prepends zeros (or a very small value if eps is True) + to the first (not zeroth) dimension of tensor. + """ + kwargs = dict(device=tensor.device, dtype=tensor.dtype) + shape: List[int] = list(tensor.shape) + shape[1] = 1 + if not eps: + dummy = torch.zeros(shape, **kwargs) # type: ignore + else: + dummy = torch.full(shape, EPSILON, **kwargs) # type: ignore + output = torch.cat((dummy, tensor), dim=1) + return output def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: """Given a doc, give the mention clusters. diff --git a/spacy/ml/models/coref_util_wl.py b/spacy/ml/models/coref_util_wl.py deleted file mode 100644 index 20a5f40c455..00000000000 --- a/spacy/ml/models/coref_util_wl.py +++ /dev/null @@ -1,134 +0,0 @@ -""" Contains functions not directly linked to coreference resolution """ - -from typing import List, Set, Dict, Tuple -from thinc.types import Ints1d -from dataclasses import dataclass -from ...tokens import Doc -from ...language import Language - -import torch - -EPSILON = 1e-7 - -class GraphNode: - def __init__(self, node_id: int): - self.id = node_id - self.links: Set[GraphNode] = set() - self.visited = False - - def link(self, another: "GraphNode"): - self.links.add(another) - another.links.add(self) - - def __repr__(self) -> str: - return str(self.id) - - -def add_dummy(tensor: torch.Tensor, eps: bool = False): - """ Prepends zeros (or a very small value if eps is True) - to the first (not zeroth) dimension of tensor. - """ - kwargs = dict(device=tensor.device, dtype=tensor.dtype) - shape: List[int] = list(tensor.shape) - shape[1] = 1 - if not eps: - dummy = torch.zeros(shape, **kwargs) # type: ignore - else: - dummy = torch.full(shape, EPSILON, **kwargs) # type: ignore - output = torch.cat((dummy, tensor), dim=1) - return output - - - -# TODO replace with spaCy config -@dataclass -class CorefConfig: # pylint: disable=too-many-instance-attributes, too-few-public-methods - """ Contains values needed to set up the coreference model. """ - section: str - - data_dir: str - - train_data: str - dev_data: str - test_data: str - - device: str - - bert_model: str - bert_window_size: int - - embedding_size: int - sp_embedding_size: int - a_scoring_batch_size: int - hidden_size: int - n_hidden_layers: int - - max_span_len: int - - rough_k: int - - bert_finetune: bool - bert_mini_finetune: bool - dropout_rate: float - learning_rate: float - bert_learning_rate: float - train_epochs: int - bce_loss_weight: float - - tokenizer_kwargs: Dict[str, dict] - conll_log_dir: str - - -def get_sent_ids(doc): - sid = 0 - sids = [] - for sent in doc.sents: - for tok in sent: - sids.append(sid) - sid += 1 - return sids - -def get_cluster_ids(doc): - """Get the cluster ids of head tokens.""" - - out = [0] * len(doc) - head_spangroups = [doc.spans[sk] for sk in doc.spans if sk.startswith("coref_word_clusters")] - for ii, group in enumerate(head_spangroups, start=1): - for span in group: - out[span[0].i] = ii - - return out - -def get_head2span(doc): - out = [] - for sk in doc.spans: - if not sk.startswith("coref_clusters"): - continue - - if len(doc.spans[sk]) == 1: - print("===== UNARY MENTION ====") - - for span in doc.spans[sk]: - out.append( (span.root.i, span.start, span.end) ) - return out - - -def doc2tensors( - xp, - doc: Doc -) -> Tuple[Ints1d, Ints1d, Ints1d, Ints1d, Ints1d]: - sent_ids = get_sent_ids(doc) - cluster_ids = get_cluster_ids(doc) - head2span = get_head2span(doc) - - - if not head2span: - heads, starts, ends = [], [], [] - else: - heads, starts, ends = zip(*head2span) - sent_ids = xp.asarray(sent_ids) - cluster_ids = xp.asarray(cluster_ids) - heads = xp.asarray(heads) - starts = xp.asarray(starts) - ends = xp.asarray(ends) - 1 - return sent_ids, cluster_ids, heads, starts, ends From d0ae2590db9e0c18b24c62c0eb79b99371078c14 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 15 Mar 2022 20:05:24 +0900 Subject: [PATCH 088/188] Delete all the coref-hoi code --- spacy/ml/models/coref.py | 446 +-------------------------------------- 1 file changed, 1 insertion(+), 445 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 139eaca8538..c584ac659f8 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -11,457 +11,13 @@ from ...util import registry from ..extract_spans import extract_spans -from .coref_util import get_candidate_mentions, select_non_crossing_spans, topk - - -@registry.architectures("spacy.Coref.v1") -def build_coref( - tok2vec: Model[List[Doc], List[Floats2d]], - get_mentions: Any = get_candidate_mentions, - hidden: int = 1000, - dropout: float = 0.3, - mention_limit: int = 3900, - # TODO this needs a better name. It limits the max mentions as a ratio of - # the token count. - mention_limit_ratio: float = 0.4, - max_span_width: int = 20, - antecedent_limit: int = 50, -): - dim = tok2vec.get_dim("nO") * 3 - - span_embedder = build_span_embedder(get_mentions, max_span_width) - - with Model.define_operators({">>": chain, "&": tuplify, "+": add}): - - mention_scorer = ( - Linear(nI=dim, nO=hidden) - >> Relu(nI=hidden, nO=hidden) - >> Dropout(dropout) - >> Linear(nI=hidden, nO=hidden) - >> Relu(nI=hidden, nO=hidden) - >> Dropout(dropout) - >> Linear(nI=hidden, nO=1) - ) - mention_scorer.initialize() - - # TODO make feature_embed_size a param - feature_embed_size = 20 - width_scorer = build_width_scorer(max_span_width, hidden, feature_embed_size) - - bilinear = Linear(nI=dim, nO=dim) >> Dropout(dropout) - bilinear.initialize() - - ms = (build_take_vecs() >> mention_scorer) + width_scorer - - model = ( - (tok2vec & noop()) - >> span_embedder - >> (ms & noop()) - >> build_coarse_pruner(mention_limit, mention_limit_ratio) - >> build_ant_scorer(bilinear, Dropout(dropout), antecedent_limit) - ) - return model - - -@dataclass -class SpanEmbeddings: - indices: Ints2d # Array with 2 columns (for start and end index) - vectors: Ragged # Ragged[Floats2d] # One vector per span - # NB: We assume that the indices refer to a concatenated Floats2d that - # has one row per token in the *batch* of documents. This makes it unambiguous - # which row is in which document, because if the lengths are e.g. [10, 5], - # a span starting at 11 must be starting at token 2 of doc 1. A bug could - # potentially cause you to have a span which crosses a doc boundary though, - # which would be bad. - # The lengths in the Ragged are not the tokens per doc, but the number of - # mentions per doc. - - def __add__(self, right): - out = self.vectors.data + right.vectors.data - return SpanEmbeddings(self.indices, Ragged(out, self.vectors.lengths)) - - def __iadd__(self, right): - self.vectors.data += right.vectors.data - return self - - -def build_width_scorer(max_span_width, hidden_size, feature_embed_size=20): - span_width_prior = ( - Embed(nV=max_span_width, nO=feature_embed_size) - >> Linear(nI=feature_embed_size, nO=hidden_size) - >> Relu(nI=hidden_size, nO=hidden_size) - >> Dropout() - >> Linear(nI=hidden_size, nO=1) - ) - span_width_prior.initialize() - model = Model("WidthScorer", forward=width_score_forward, layers=[span_width_prior]) - model.set_ref("width_prior", span_width_prior) - return model - - -def width_score_forward( - model, embeds: SpanEmbeddings, is_train -) -> Tuple[Floats1d, Callable]: - # calculate widths, subtracting 1 so it's 0-index - w_ffnn = model.get_ref("width_prior") - idxs = embeds.indices - widths = idxs[:, 1] - idxs[:, 0] - 1 - wscores, width_b = w_ffnn(widths, is_train) - - lens = embeds.vectors.lengths - - def width_score_backward(d_score: Floats1d) -> SpanEmbeddings: - - dX = width_b(d_score) - vecs = Ragged(dX, lens) - return SpanEmbeddings(idxs, vecs) - - return wscores, width_score_backward - - -# model converting a Doc/Mention to span embeddings -# get_mentions: Callable[Doc, Pairs[int]] -def build_span_embedder( - get_mentions: Callable, - max_span_width: int = 20, -) -> Model[Tuple[List[Floats2d], List[Doc]], SpanEmbeddings]: - - with Model.define_operators({">>": chain, "|": concatenate}): - span_reduce = extract_spans() >> ( - reduce_first() | reduce_last() | reduce_mean() - ) - model = Model( - "SpanEmbedding", - forward=span_embeddings_forward, - attrs={ - "get_mentions": get_mentions, - # XXX might be better to make this an implicit parameter in the - # mention generator - "max_span_width": max_span_width, - }, - layers=[span_reduce], - ) - model.set_ref("span_reducer", span_reduce) - return model - - -def span_embeddings_forward( - model, inputs: Tuple[List[Floats2d], List[Doc]], is_train -) -> Tuple[SpanEmbeddings, Callable]: - ops = model.ops - xp = ops.xp - - tokvecs, docs = inputs - - # TODO fix this - dim = tokvecs[0].shape[1] - - get_mentions = model.attrs["get_mentions"] - max_span_width = model.attrs["max_span_width"] - mentions = ops.alloc2i(0, 2) - docmenlens = [] # number of mentions per doc - - for doc in docs: - starts, ends = get_mentions(doc, max_span_width) - docmenlens.append(len(starts)) - cments = ops.asarray2i([starts, ends]).transpose() - - mentions = xp.concatenate((mentions, cments)) - - # TODO support attention here - tokvecs = xp.concatenate(tokvecs) - doclens = [len(doc) for doc in docs] - tokvecs_r = Ragged(tokvecs, doclens) - mentions_r = Ragged(mentions, docmenlens) - - span_reduce = model.get_ref("span_reducer") - spanvecs, span_reduce_back = span_reduce((tokvecs_r, mentions_r), is_train) - - embeds = Ragged(spanvecs, docmenlens) - - def backprop_span_embed(dY: SpanEmbeddings) -> Tuple[List[Floats2d], List[Doc]]: - grad, idxes = span_reduce_back(dY.vectors.data) - - oweights = [] - offset = 0 - for doclen in doclens: - hi = offset + doclen - oweights.append(grad.data[offset:hi]) - offset = hi - - return oweights, docs - - return SpanEmbeddings(mentions, embeds), backprop_span_embed - - -def build_coarse_pruner( - mention_limit: int, - mention_limit_ratio: float, -) -> Model[SpanEmbeddings, SpanEmbeddings]: - model = Model( - "CoarsePruner", - forward=coarse_prune, - attrs={ - "mention_limit": mention_limit, - "mention_limit_ratio": mention_limit_ratio, - }, - ) - return model - - -def coarse_prune( - model, inputs: Tuple[Floats1d, SpanEmbeddings], is_train -) -> Tuple[Tuple[Floats1d, SpanEmbeddings], Callable]: - """Given scores for mention, output the top non-crossing mentions. - - Mentions can contain other mentions, but candidate mentions cannot cross each other. - """ - rawscores, spanembeds = inputs - scores = rawscores.flatten() - mention_limit = model.attrs["mention_limit"] - mention_limit_ratio = model.attrs["mention_limit_ratio"] - # XXX: Issue here. Don't need docs to find crossing spans, but might for the limits. - # In old code the limit can be: - # - hard number per doc - # - ratio of tokens in the doc - - offset = 0 - selected = [] - sellens = [] - for menlen in spanembeds.vectors.lengths: - hi = offset + menlen - cscores = scores[offset:hi] - - # negate it so highest numbers come first - # This is relatively slow but can't be skipped. - tops = (model.ops.xp.argsort(-1 * cscores)).tolist() - starts = spanembeds.indices[offset:hi, 0].tolist() - ends = spanembeds.indices[offset:hi:, 1].tolist() - - # calculate the doc length - doclen = ends[-1] - starts[0] - # XXX seems to make more sense to use menlen than doclen here? - # coref-hoi uses doclen (number of words). - mlimit = min(mention_limit, int(mention_limit_ratio * doclen)) - # csel is a 1d integer list - csel = select_non_crossing_spans(tops, starts, ends, mlimit) - # add the offset so these indices are absolute - csel = [ii + offset for ii in csel] - # this should be constant because short choices are padded - sellens.append(len(csel)) - selected += csel - offset += menlen - - selected = model.ops.asarray1i(selected) - top_spans = spanembeds.indices[selected] - top_vecs = spanembeds.vectors.data[selected] - - out = SpanEmbeddings(top_spans, Ragged(top_vecs, sellens)) - - # save some variables so the embeds can be garbage collected - idxlen = spanembeds.indices.shape[0] - vecshape = spanembeds.vectors.data.shape - indices = spanembeds.indices - veclens = out.vectors.lengths - - def coarse_prune_backprop( - dY: Tuple[Floats1d, SpanEmbeddings] - ) -> Tuple[Floats1d, SpanEmbeddings]: - - dYscores, dYembeds = dY - - dXscores = model.ops.alloc1f(idxlen) - dXscores[selected] = dYscores.flatten() - - dXvecs = model.ops.alloc2f(*vecshape) - dXvecs[selected] = dYembeds.vectors.data - rout = Ragged(dXvecs, veclens) - dXembeds = SpanEmbeddings(indices, rout) - - # inflate for mention scorer - dXscores = model.ops.xp.expand_dims(dXscores, 1) - - return (dXscores, dXembeds) - - return (scores[selected], out), coarse_prune_backprop - - -def build_take_vecs() -> Model[SpanEmbeddings, Floats2d]: - # this just gets vectors out of spanembeddings - # XXX Might be better to convert SpanEmbeddings to a tuple and use with_getitem - return Model("TakeVecs", forward=take_vecs_forward) - - -def take_vecs_forward(model, inputs: SpanEmbeddings, is_train) -> Floats2d: - idxs = inputs.indices - lens = inputs.vectors.lengths - - def backprop(dY: Floats2d) -> SpanEmbeddings: - vecs = Ragged(dY, lens) - return SpanEmbeddings(idxs, vecs) - - return inputs.vectors.data, backprop - - -def build_ant_scorer( - bilinear, dropout, ant_limit=50 -) -> Model[Tuple[Floats1d, SpanEmbeddings], List[Floats2d]]: - model = Model( - "AntScorer", - forward=ant_scorer_forward, - layers=[bilinear, dropout], - attrs={ - "ant_limit": ant_limit, - }, - ) - model.set_ref("bilinear", bilinear) - model.set_ref("dropout", dropout) - return model - - -def ant_scorer_forward( - model, inputs: Tuple[Floats1d, SpanEmbeddings], is_train -) -> Tuple[Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d], Callable]: - ops = model.ops - xp = ops.xp - - ant_limit = model.attrs["ant_limit"] - # this contains the coarse bilinear in coref-hoi - # coarse bilinear is a single layer linear network - # TODO make these proper refs - bilinear = model.get_ref("bilinear") - dropout = model.get_ref("dropout") - - mscores, sembeds = inputs - vecs = sembeds.vectors # ragged - - offset = 0 - backprops = [] - out = [] - for ll in vecs.lengths: - hi = offset + ll - # each iteration is one doc - - # first calculate the pairwise product scores - cvecs = vecs.data[offset:hi] - pw_prod, prod_back = pairwise_product(bilinear, dropout, cvecs, is_train) - - # now calculate the pairwise mention scores - ms = mscores[offset:hi].flatten() - pw_sum, pw_sum_back = pairwise_sum(ops, ms) - - # make a mask so antecedents precede referrents - ant_range = xp.arange(0, cvecs.shape[0]) - - # This will take the log of 0, which causes a warning, but we're doing - # it on purpose so we can just ignore the warning. - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=RuntimeWarning) - mask = xp.log( - (xp.expand_dims(ant_range, 1) - xp.expand_dims(ant_range, 0)) >= 1 - ).astype("f") - - scores = pw_prod + pw_sum + mask - - top_limit = min(ant_limit, len(scores)) - top_scores, top_scores_idx = topk(xp, scores, top_limit) - # now add the placeholder - placeholder = ops.alloc2f(scores.shape[0], 1) - top_scores = xp.concatenate((placeholder, top_scores), 1) - - out.append((top_scores, top_scores_idx)) - - # In the full model these scores can be further refined. In the current - # state of this model we're done here, so this pruning is less important, - # but it's still helpful for reducing memory usage (since scores can be - # garbage collected when the loop exits). - - offset += ll - backprops.append((prod_back, pw_sum_back)) - - # save vars for gc - vecshape = vecs.data.shape - veclens = vecs.lengths - scoreshape = mscores.shape - idxes = sembeds.indices - - def backprop( - dYs: Tuple[List[Tuple[Floats2d, Ints2d]], Ints2d] - ) -> Tuple[Floats2d, SpanEmbeddings]: - dYscores, dYembeds = dYs - dXembeds = Ragged(ops.alloc2f(*vecshape), veclens) - dXscores = ops.alloc1f(*scoreshape) - - offset = 0 - for dy, (prod_back, pw_sum_back), ll in zip(dYscores, backprops, veclens): - hi = offset + ll - dyscore, dyidx = dy - # remove the placeholder - dyscore = dyscore[:, 1:] - # the full score grid is square - - fullscore = ops.alloc2f(ll, ll) - for ii, (ridx, rscores) in enumerate(zip(dyidx, dyscore)): - fullscore[ii][ridx] = rscores - - dXembeds.data[offset:hi] = prod_back(fullscore) - dXscores[offset:hi] = pw_sum_back(fullscore) - - offset = hi - # make it fit back into the linear - dXscores = xp.expand_dims(dXscores, 1) - return (dXscores, SpanEmbeddings(idxes, dXembeds)) - - return (out, sembeds.indices), backprop - - -def pairwise_sum(ops, mention_scores: Floats1d) -> Tuple[Floats2d, Callable]: - """Find the most likely mention-antecedent pairs.""" - # This doesn't use multiplication because two items with low mention scores - # don't make a good candidate pair. - - pw_sum = ops.xp.expand_dims(mention_scores, 1) + ops.xp.expand_dims( - mention_scores, 0 - ) - - def backward(d_pwsum: Floats2d) -> Floats1d: - # For the backward pass, the gradient is distributed over the whole row and - # column, so pull it all in. - - out = d_pwsum.sum(axis=0) + d_pwsum.sum(axis=1) - - return out - - return pw_sum, backward - - -def pairwise_product(bilinear, dropout, vecs: Floats2d, is_train): - # A neat side effect of this is that we don't have to pass the backprops - # around separately because the closure handles them. - source, source_b = bilinear(vecs, is_train) - target, target_b = dropout(vecs.T, is_train) - pw_prod = source @ target - - def backward(d_prod: Floats2d) -> Floats2d: - dS = source_b(d_prod @ target.T) - dT = target_b(source.T @ d_prod) - dX = dS + dT.T - return dX - - return pw_prod, backward - - -# XXX here down is wl-coref -from typing import List, Tuple - import torch from thinc.util import xp2torch, torch2xp -# TODO rename this to coref_util from .coref_util import add_dummy # TODO rename to plain coref -@registry.architectures("spacy.WLCoref.v1") +@registry.architectures("spacy.Coref.v1") def build_wl_coref_model( tok2vec: Model[List[Doc], List[Floats2d]], embedding_size: int = 20, From 5650853c0f1583c8b5e5ff4928d7d9dc71e95c7c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 16 Mar 2022 14:38:11 +0900 Subject: [PATCH 089/188] Remove unused functions --- spacy/ml/models/coref_util.py | 64 ----------------------------------- 1 file changed, 64 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index d45cdc81008..c75314fa614 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -56,21 +56,6 @@ def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: return out -def topk(xp, arr, k, axis=1): - """Given an array and a k value, give the top values and idxs for each row.""" - - part = xp.argpartition(arr, -k, axis=axis) - idxs = xp.flip(part)[:, :k] - - vals = xp.take_along_axis(arr, idxs, axis=axis) - - sidxs = xp.argsort(-vals, axis=axis) - # map these idxs back to the original - oidxs = xp.take_along_axis(idxs, sidxs, axis=axis) - svals = xp.take_along_axis(vals, sidxs, axis=axis) - return svals, oidxs - - # from model.py, refactored to be non-member def get_predicted_antecedents(xp, antecedent_idx, antecedent_scores): """Get the ID of the antecedent for each span. -1 if no antecedent.""" @@ -124,55 +109,6 @@ def get_predicted_clusters( return predicted_clusters -def get_sentence_map(doc: Doc): - """For the given span, return a list of sentence indexes.""" - if doc.has_annotation("SENT_START"): - si = 0 - out = [] - for sent in doc.sents: - for _ in sent: - out.append(si) - si += 1 - return out - else: - # If there are no sents then just return dummy values. - # Shouldn't happen in general training, but typical in init. - return [0] * len(doc) - - -def get_candidate_mentions( - doc: Doc, max_span_width: int = 20 -) -> Tuple[List[int], List[int]]: - """Given a Doc, return candidate mentions. - - This isn't a trainable layer, it just returns raw candidates. - """ - # XXX Note that in coref-hoi the indexes are designed so you actually want [i:j+1], but here - # we're using [i:j], which is more natural. - - sentence_map = get_sentence_map(doc) - - begins = [] - ends = [] - for tok in doc: - si = sentence_map[tok.i] # sentence index - for ii in range(1, max_span_width): - ei = tok.i + ii # end index - - # Note: this matches slice syntax, so the token index is one less - if ei > len(doc) or sentence_map[ei - 1] != si: - break - - begins.append(tok.i) - ends.append(ei) - - return (begins, ends) - - -@registry.misc("spacy.CorefCandidateGenerator.v1") -def create_mention_generator() -> Any: - return get_candidate_mentions - def select_non_crossing_spans( idxs: List[int], starts: List[int], ends: List[int], limit: int From 7811a1194b710bcb2dc6ebaadfef235b6c50492c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 16 Mar 2022 14:57:15 +0900 Subject: [PATCH 090/188] Change architecture --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 82a36947372..97aa33cf2ff 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -30,7 +30,7 @@ default_config = """ [model] -@architectures = "spacy.WLCoref.v1" +@architectures = "spacy.Coref.v1" embedding_size = 20 hidden_size = 1024 n_hidden_layers = 1 From 6974f55daae4b4f5dfeae76ac40707992b961b5c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 16 Mar 2022 15:15:53 +0900 Subject: [PATCH 091/188] Hack for transformer listener size --- spacy/ml/models/coref.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index c584ac659f8..f40a4c1109c 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -32,7 +32,12 @@ def build_wl_coref_model( # span predictor embeddings sp_embedding_size: int = 64, ): - dim = tok2vec.get_dim("nO") + # TODO fix this + try: + dim = tok2vec.get_dim("nO") + except ValueError: + # happens with transformer listener + dim = 768 with Model.define_operators({">>": chain}): # TODO chain tok2vec with these models From 0275ae29de8ca183f31c9332aeaa3166cdcae248 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 16 Mar 2022 20:09:12 +0900 Subject: [PATCH 092/188] Remove stale comment --- spacy/ml/models/coref.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index f40a4c1109c..049f0efaebd 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -16,7 +16,6 @@ from .coref_util import add_dummy -# TODO rename to plain coref @registry.architectures("spacy.Coref.v1") def build_wl_coref_model( tok2vec: Model[List[Doc], List[Floats2d]], From 6855df0e66655766a407f0728271bad701e91f8e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 16 Mar 2022 20:09:33 +0900 Subject: [PATCH 093/188] Skeleton for span predictor component This should be moved into its own file, but for now just stubbing out the methods. --- spacy/pipeline/coref.py | 89 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 97aa33cf2ff..20fdcac3840 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -117,7 +117,6 @@ def __init__( self.span_mentions = span_mentions self.span_cluster_prefix = span_cluster_prefix self._rehearsal_model = None - self.loss = CategoricalCrossentropy() self.cfg = {} @@ -389,3 +388,91 @@ def score(self, examples, **kwargs): fname = f"coref_{field}" out[fname] = mean([ss[fname] for ss in scores]) return out + +class SpanPredictor(TrainablePipe): + """Pipeline component to resolve one-token spans to full spans. + + Used in coreference resolution. + """ + + def __init__( + self, + vocab: Vocab, + model: Model, + name: str = "span_predictor", + *, + input_prefix: str = "coref_head_clusters", + output_prefix: str = "coref_clusters", + ) -> None: + self.vocab = vocab + self.model = model + self.name = name + self.input_prefix = input_prefix + self.output_prefix = output_prefix + + self.cfg = {} + + def predict(self, docs: Iterable[Doc]): + ... + + def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: + ... + + def update( + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, + losses: Optional[Dict[str, float]] = None, + ) -> Dict[str, float]: + ... + + def rehearse( + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, + losses: Optional[Dict[str, float]] = None, + ) -> Dict[str, float]: + ... + + def add_label(self, label: str) -> int: + """Technically this method should be implemented from TrainablePipe, + but it is not relevant for this component. + """ + raise NotImplementedError( + Errors.E931.format( + parent="SpanPredictor", method="add_label", name=self.name + ) + ) + + def get_loss( + self, + examples: Iterable[Example], + #TODO add necessary args + ): + ... + + def initialize( + self, + get_examples: Callable[[], Iterable[Example]], + *, + nlp: Optional[Language] = None, + ) -> None: + validate_get_examples(get_examples, "CoreferenceResolver.initialize") + + X = [] + Y = [] + for ex in islice(get_examples(), 2): + X.append(ex.predicted) + Y.append(ex.reference) + + assert len(X) > 0, Errors.E923.format(name=self.name) + self.model.initialize(X=X, Y=Y) + + def score(self, examples, **kwargs): + # TODO this will overlap significantly with coref, maybe factor into function + ... + From 1a79d18796038e3805abf9618207eca82e7dad40 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 16 Mar 2022 20:10:47 +0900 Subject: [PATCH 094/188] Formatting --- spacy/pipeline/coref.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 20fdcac3840..f65409a800a 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -60,6 +60,7 @@ DEFAULT_CLUSTERS_PREFIX = "coref_clusters" + @Language.factory( "coref", assigns=["doc.spans"], @@ -133,7 +134,7 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: # each item in scores includes scores and a mapping from scores to mentions ant_idxs = idxs - #TODO batching + # TODO batching xp = self.model.ops.xp starts = xp.arange(0, len(docs[0])) @@ -242,7 +243,7 @@ def rehearse( if self._rehearsal_model is None: return losses validate_examples(examples, "CoreferenceResolver.rehearse") - #TODO test this whole function + # TODO test this whole function docs = [eg.predicted for eg in examples] if not any(len(doc) for doc in docs): # Handle cases where there are no tokens in any docs. @@ -256,7 +257,7 @@ def rehearse( if sgd is not None: self.finish_update(sgd) if losses is not None: - losses[self.name] += (gradient ** 2).sum() + losses[self.name] += (gradient**2).sum() return losses def add_label(self, label: str) -> int: @@ -290,13 +291,13 @@ def get_loss( offset = 0 gradients = [] total_loss = 0 - #TODO change this + # TODO change this # 1. do not handle batching (add it back later) # 2. don't do index conversion (no mentions, just word indices) # 3. convert words to spans (if necessary) in gold and predictions - + # massage score matrix to be shaped correctly - score_matrix = [ (score_matrix, None) ] + score_matrix = [(score_matrix, None)] for example, (cscores, cidx) in zip(examples, score_matrix): ll = cscores.shape[0] @@ -306,7 +307,7 @@ def get_loss( span_idxs = create_head_span_idxs(ops, len(example.predicted)) gscores = create_gold_scores(span_idxs, clusters) gscores = ops.asarray2f(gscores) - #top_gscores = xp.take_along_axis(gscores, cidx, axis=1) + # top_gscores = xp.take_along_axis(gscores, cidx, axis=1) top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) # now add the placeholder gold_placeholder = ~top_gscores.any(axis=1).T @@ -322,7 +323,7 @@ def get_loss( log_norm = ops.softmax(cscores, axis=1) grad = log_norm - log_marg gradients.append((grad, cidx)) - total_loss += float((grad ** 2).sum()) + total_loss += float((grad**2).sum()) offset = hi @@ -389,6 +390,7 @@ def score(self, examples, **kwargs): out[fname] = mean([ss[fname] for ss in scores]) return out + class SpanPredictor(TrainablePipe): """Pipeline component to resolve one-token spans to full spans. @@ -419,21 +421,21 @@ def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: ... def update( - self, - examples: Iterable[Example], - *, - drop: float = 0.0, - sgd: Optional[Optimizer] = None, + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, losses: Optional[Dict[str, float]] = None, ) -> Dict[str, float]: ... def rehearse( - self, - examples: Iterable[Example], - *, - drop: float = 0.0, - sgd: Optional[Optimizer] = None, + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, losses: Optional[Dict[str, float]] = None, ) -> Dict[str, float]: ... @@ -451,7 +453,7 @@ def add_label(self, label: str) -> int: def get_loss( self, examples: Iterable[Example], - #TODO add necessary args + # TODO add necessary args ): ... @@ -475,4 +477,3 @@ def initialize( def score(self, examples, **kwargs): # TODO this will overlap significantly with coref, maybe factor into function ... - From a0988491120648026f58555e99e9f5dbf4d7f7df Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 18 Mar 2022 19:46:58 +0900 Subject: [PATCH 095/188] Add fake batching The way fake batching works is that the pipeline component calls the model repeatedly in a loop internally. It feels like this should break something, but it worked in testing. Another issue is that this changes the signature of some of the pipeline functions, though I don't think that's an issue. Tested with batch size of 2, so more testing is needed, but this is a start. --- spacy/pipeline/coref.py | 115 +++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 61 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index f65409a800a..4b1483e3c52 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -129,22 +129,24 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: DOCS: https://spacy.io/api/coref#predict (TODO) """ - scores, idxs = self.model.predict(docs) - # idxs is a list of mentions (start / end idxs) - # each item in scores includes scores and a mapping from scores to mentions - ant_idxs = idxs + #print("DOCS", docs) + out = [] + for doc in docs: + scores, idxs = self.model.predict([doc]) + # idxs is a list of mentions (start / end idxs) + # each item in scores includes scores and a mapping from scores to mentions + ant_idxs = idxs - # TODO batching - xp = self.model.ops.xp + # TODO batching + xp = self.model.ops.xp - starts = xp.arange(0, len(docs[0])) - ends = xp.arange(0, len(docs[0])) + 1 + starts = xp.arange(0, len(doc)) + ends = xp.arange(0, len(doc)) + 1 - predicted = get_predicted_clusters(xp, starts, ends, ant_idxs, scores) + predicted = get_predicted_clusters(xp, starts, ends, ant_idxs, scores) + out.append(predicted) - clusters_by_doc = [predicted] - - return clusters_by_doc + return out def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: """Modify a batch of Doc objects, using pre-computed scores. @@ -203,17 +205,21 @@ def update( return losses set_dropout_rate(self.model, drop) - inputs = [example.predicted for example in examples] - preds, backprop = self.model.begin_update(inputs) - score_matrix, mention_idx = preds + total_loss = 0 - loss, d_scores = self.get_loss(examples, score_matrix, mention_idx) - # TODO check shape here - backprop((d_scores, mention_idx)) + for eg in examples: + # TODO does this even work? + preds, backprop = self.model.begin_update([eg.predicted]) + score_matrix, mention_idx = preds + + loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) + total_loss += loss + # TODO check shape here + backprop((d_scores, mention_idx)) if sgd is not None: self.finish_update(sgd) - losses[self.name] += loss + losses[self.name] += total_loss return losses def rehearse( @@ -288,48 +294,35 @@ def get_loss( ops = self.model.ops xp = ops.xp - offset = 0 - gradients = [] - total_loss = 0 - # TODO change this - # 1. do not handle batching (add it back later) - # 2. don't do index conversion (no mentions, just word indices) - # 3. convert words to spans (if necessary) in gold and predictions - - # massage score matrix to be shaped correctly - score_matrix = [(score_matrix, None)] - for example, (cscores, cidx) in zip(examples, score_matrix): - - ll = cscores.shape[0] - hi = offset + ll - - clusters = get_clusters_from_doc(example.reference) - span_idxs = create_head_span_idxs(ops, len(example.predicted)) - gscores = create_gold_scores(span_idxs, clusters) - gscores = ops.asarray2f(gscores) - # top_gscores = xp.take_along_axis(gscores, cidx, axis=1) - top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) - # now add the placeholder - gold_placeholder = ~top_gscores.any(axis=1).T - gold_placeholder = xp.expand_dims(gold_placeholder, 1) - top_gscores = xp.concatenate((gold_placeholder, top_gscores), 1) - - # boolean to float - top_gscores = ops.asarray2f(top_gscores) - - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=RuntimeWarning) - log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) - log_norm = ops.softmax(cscores, axis=1) - grad = log_norm - log_marg - gradients.append((grad, cidx)) - total_loss += float((grad**2).sum()) - - offset = hi - - # Undo the wrapping - gradients = gradients[0][0] - return total_loss, gradients + # TODO if there is more than one example, give an error + # (or actually rework this to take multiple things) + example = examples[0] + cscores = score_matrix + cidx = mention_idx + + clusters = get_clusters_from_doc(example.reference) + span_idxs = create_head_span_idxs(ops, len(example.predicted)) + gscores = create_gold_scores(span_idxs, clusters) + gscores = ops.asarray2f(gscores) + # top_gscores = xp.take_along_axis(gscores, cidx, axis=1) + top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) + # now add the placeholder + gold_placeholder = ~top_gscores.any(axis=1).T + gold_placeholder = xp.expand_dims(gold_placeholder, 1) + top_gscores = xp.concatenate((gold_placeholder, top_gscores), 1) + + # boolean to float + top_gscores = ops.asarray2f(top_gscores) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=RuntimeWarning) + log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) + log_norm = ops.softmax(cscores, axis=1) + grad = log_norm - log_marg + #gradients.append((grad, cidx)) + loss = float((grad**2).sum()) + + return loss, grad def initialize( self, From db422abf011fb9b0dabde5e22b9d7fa0b05424b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Fri, 18 Mar 2022 16:24:26 +0100 Subject: [PATCH 096/188] remove unnecessary .device --- spacy/ml/models/coref.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index f40a4c1109c..fea4bc21af9 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -38,14 +38,11 @@ def build_wl_coref_model( except ValueError: # happens with transformer listener dim = 768 - + with Model.define_operators({">>": chain}): # TODO chain tok2vec with these models - # TODO fix device - should be automatic - device = "cuda:0" coref_scorer = PyTorchWrapper( CorefScorer( - device, dim, embedding_size, hidden_size, @@ -65,7 +62,6 @@ def build_wl_coref_model( # TODO this was hardcoded to 1024, check hidden_size, sp_embedding_size, - device ), convert_inputs=convert_span_predictor_inputs @@ -205,7 +201,6 @@ class CorefScorer(torch.nn.Module): """ def __init__( self, - device: str, dim: int, # tok2vec size dist_emb_size: int, hidden_size: int, @@ -222,8 +217,7 @@ def __init__( epochs_trained (int): the number of epochs finished (useful for warm start) """ - # device, dist_emb_size, hidden_size, n_layers, dropout_rate - self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate).to(device) + self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate) #TODO clean this up bert_emb = dim pair_emb = bert_emb * 3 + self.pw.shape @@ -232,7 +226,7 @@ def __init__( hidden_size, n_layers, dropout_rate - ).to(device) + ) self.lstm = torch.nn.LSTM( input_size=bert_emb, hidden_size=bert_emb, @@ -243,7 +237,7 @@ def __init__( bert_emb, dropout_rate, roughk - ).to(device) + ) self.batch_size = batch_size def forward( @@ -392,7 +386,6 @@ def _get_pair_matrix(all_mentions: torch.Tensor, return out - class RoughScorer(torch.nn.Module): """ Is needed to give a roughly estimate of the anaphoricity of two candidates, @@ -423,7 +416,6 @@ def forward( pair_mask = torch.arange(mentions.shape[0]) pair_mask = pair_mask.unsqueeze(1) - pair_mask.unsqueeze(0) pair_mask = torch.log((pair_mask > 0).to(torch.float)) - pair_mask = pair_mask.to(mentions.device) bilinear_scores = self.dropout(self.bilinear(mentions)).mm(mentions.T) rough_scores = pair_mask + bilinear_scores @@ -450,7 +442,7 @@ def _prune(self, class SpanPredictor(torch.nn.Module): - def __init__(self, input_size: int, distance_emb_size: int, device): + def __init__(self, input_size: int, distance_emb_size: int): super().__init__() self.ffnn = torch.nn.Sequential( torch.nn.Linear(input_size * 2 + 64, input_size), @@ -461,7 +453,6 @@ def __init__(self, input_size: int, distance_emb_size: int, device): torch.nn.Dropout(0.3), torch.nn.Linear(256, 64), ) - self.device = device self.conv = torch.nn.Sequential( torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) @@ -529,6 +520,8 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in valid_positions = torch.stack((valid_starts, valid_ends), dim=2) return scores + valid_positions return scores + + class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): @@ -538,17 +531,10 @@ def __init__(self, embedding_size, dropout_rate): self.dropout = torch.nn.Dropout(dropout_rate) self.shape = emb_size - @property - def device(self) -> torch.device: - """ A workaround to get current device (which is assumed to be the - device of the first parameter of one of the submodules) """ - return next(self.distance_emb.parameters()).device - - def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch top_indices: torch.Tensor ) -> torch.Tensor: - word_ids = torch.arange(0, top_indices.size(0), device=self.device) + word_ids = torch.arange(0, top_indices.size(0)) distance = (word_ids.unsqueeze(1) - word_ids[top_indices] ).clamp_min_(min=1) log_distance = distance.to(torch.float).log2().floor_() From 2190cbc0e6efd331a76addab6a8033c9dd25fa78 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sat, 19 Mar 2022 19:39:49 +0900 Subject: [PATCH 097/188] Add progress on SpanPredictor component This isn't working. There is a CUDA error in the torch code during initialization and it's not clear why. --- spacy/ml/models/coref.py | 76 ++++++++++++-- spacy/ml/models/coref_util.py | 9 ++ spacy/pipeline/coref.py | 192 ++++++++++++++++++++++++++++++++-- 3 files changed, 260 insertions(+), 17 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 049f0efaebd..382d7a98ba8 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -14,7 +14,7 @@ import torch from thinc.util import xp2torch, torch2xp -from .coref_util import add_dummy +from .coref_util import add_dummy, get_sentence_ids @registry.architectures("spacy.Coref.v1") def build_wl_coref_model( @@ -74,6 +74,33 @@ def build_wl_coref_model( # and just return words as spans. return coref_model +@registry.architectures("spacy.SpanPredictor.v1") +def build_span_predictor( + tok2vec: Model[List[Doc], List[Floats2d]], + hidden_size: int = 1024, + dist_emb_size: int = 64, + ): + # TODO fix this + try: + dim = tok2vec.get_dim("nO") + except ValueError: + # happens with transformer listener + dim = 768 + + with Model.define_operators({">>": chain, "&": tuplify}): + # TODO fix device - should be automatic + device = "cuda:0" + span_predictor = PyTorchWrapper( + SpanPredictor(hidden_size, dist_emb_size, device), + convert_inputs=convert_span_predictor_inputs + ) + # TODO use proper parameter for prefix + head_info = build_get_head_metadata("coref_head_clusters") + model = (tok2vec & head_info) >> span_predictor + + return model + + def convert_coref_scorer_inputs( model: Model, X: List[Floats2d], @@ -84,6 +111,7 @@ def convert_coref_scorer_inputs( # TODO real batching X = X[0] + word_features = xp2torch(X, requires_grad=is_train) def backprop(args: ArgsKwargs) -> List[Floats2d]: # convert to xp and wrap in list @@ -116,10 +144,15 @@ def convert_span_predictor_inputs( X: Tuple[Ints1d, Floats2d, Ints1d], is_train: bool ): - sent_id = xp2torch(X[0], requires_grad=False) - word_features = xp2torch(X[1], requires_grad=False) - head_ids = xp2torch(X[2], requires_grad=False) - argskwargs = ArgsKwargs(args=(sent_id, word_features, head_ids), kwargs={}) + tok2vec, (sent_ids, head_ids) = X + # Normally we shoudl use the input is_train, but for these two it's not relevant + sent_ids = xp2torch(sent_ids[0], requires_grad=False) + head_ids = xp2torch(head_ids[0], requires_grad=False) + + word_features = xp2torch(tok2vec[0], requires_grad=is_train) + + argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) + # TODO actually support backprop return argskwargs, lambda dX: [] # TODO This probably belongs in the component, not the model. @@ -189,6 +222,36 @@ def _clusterize( clusters.append(sorted(cluster)) return sorted(clusters) +def build_get_head_metadata(prefix): + # TODO this name is awful, fix it + model = Model("HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward) + return model + +def head_data_forward(model, docs, is_train): + """A layer to generate the extra data needed for the span predictor. + """ + sent_ids = [] + head_ids = [] + prefix = model.attrs["prefix"] + + for doc in docs: + sids = model.ops.asarray2i(get_sentence_ids(doc)) + sent_ids.append(sids) + heads = [] + for key, sg in doc.spans.items(): + if not key.startswith(prefix): + continue + for span in sg: + # TODO warn if spans are more than one token + heads.append(span[0].i) + heads = model.ops.asarray2i(heads) + head_ids.append(heads) + + # each of these is a list with one entry per doc + # backprop is just a placeholder + # TODO it would probably be better to have a list of tuples than two lists of arrays + return (sent_ids, head_ids), lambda x: [] + class CorefScorer(torch.nn.Module): """Combines all coref modules together to find coreferent spans. @@ -492,6 +555,7 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 # Obtain "same sentence" boolean mask, [n_heads, n_words] sent_id = torch.tensor(sent_id, device=words.device) + heads_ids = heads_ids.long() same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) # To save memory, only pass candidates from one sentence for each head @@ -506,7 +570,7 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in ), dim=1) lengths = same_sent.sum(dim=1) - padding_mask = torch.arange(0, lengths.max(), device=words.device).unsqueeze(0) + padding_mask = torch.arange(0, lengths.max().item(), device=words.device).unsqueeze(0) padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index c75314fa614..e8de1e0acbf 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -39,6 +39,15 @@ def add_dummy(tensor: torch.Tensor, eps: bool = False): output = torch.cat((dummy, tensor), dim=1) return output +def get_sentence_ids(doc): + out = [] + sent_id = -1 + for tok in doc: + if tok.is_sent_start: + sent_id += 1 + out.append(sent_id) + return out + def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: """Given a doc, give the mention clusters. diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 4b1483e3c52..54e9d8cfdc9 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -1,7 +1,7 @@ from typing import Iterable, Tuple, Optional, Dict, Callable, Any, List import warnings -from thinc.types import Floats2d, Ints2d +from thinc.types import Floats2d, Floats3d, Ints2d from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy from thinc.api import set_dropout_rate from itertools import islice @@ -84,6 +84,7 @@ def make_coref( ) + class CoreferenceResolver(TrainablePipe): """Pipeline component for coreference resolution. @@ -208,7 +209,7 @@ def update( total_loss = 0 for eg in examples: - # TODO does this even work? + # TODO check this causes no issues (in practice it runs) preds, backprop = self.model.begin_update([eg.predicted]) score_matrix, mention_idx = preds @@ -384,6 +385,52 @@ def score(self, examples, **kwargs): return out +default_span_predictor_config = """ +[model] +@architectures = "spacy.SpanPredictor.v1" +hidden_size = 1024 +dist_emb_size = 64 + +[model.tok2vec] +@architectures = "spacy.Tok2Vec.v2" + +[model.tok2vec.embed] +@architectures = "spacy.MultiHashEmbed.v1" +width = 64 +rows = [2000, 2000, 1000, 1000, 1000, 1000] +attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] +include_static_vectors = false + +[model.tok2vec.encode] +@architectures = "spacy.MaxoutWindowEncoder.v2" +width = ${model.tok2vec.embed.width} +window_size = 1 +maxout_pieces = 3 +depth = 2 +""" +DEFAULT_SPAN_PREDICTOR_MODEL = Config().from_str(default_span_predictor_config)["model"] + +@Language.factory( + "span_predictor", + assigns=["doc.spans"], + requires=["doc.spans"], + default_config={ + "model": DEFAULT_SPAN_PREDICTOR_MODEL, + "input_prefix": "coref_head_clusters", + "output_prefix": "coref_clusters", + }, + default_score_weights={"span_predictor_f": 1.0, "span_predictor_p": None, "span_predictor_r": None}, + ) +def make_span_predictor( + nlp: Language, + name: str, + model, + input_prefix: str = "coref_head_clusters", + output_prefix: str = "coref_clusters", +) -> "SpanPredictor": + """Create a SpanPredictor component.""" + return SpanPredictor(nlp.vocab, model, name, input_prefix=input_prefix, output_prefix=output_prefix) + class SpanPredictor(TrainablePipe): """Pipeline component to resolve one-token spans to full spans. @@ -407,11 +454,41 @@ def __init__( self.cfg = {} - def predict(self, docs: Iterable[Doc]): - ... + def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: + # for now pretend there's just one doc + + out = [] + for doc in docs: + # TODO check shape here + span_scores = self.model.predict(doc) + span_scores = span_scores[0] + # the information about clustering has to come from the input docs + # first let's convert the scores to a list of span idxs + start_scores = span_scores[:, :, 0] + end_scores = span_scores[:, :, 1] + starts = start_scores.argmax(axis=1) + ends = end_scores.argmax(axis=1) + + # TODO check start < end + + # get the old clusters (shape will be preserved) + clusters = doc2clusters(doc, self.input_prefix) + cidx = 0 + out_clusters = [] + for cluster in clusters: + ncluster = [] + for mention in cluster: + ncluster.append( (starts[cidx], ends[cidx]) ) + cidx += 1 + out_clusters.append(ncluster) + out.append(out_clusters) + return out def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: - ... + for doc, clusters in zip(docs, clusters_by_doc): + for ii, cluster in enumerate(clusters): + spans = [doc[mm[0]:mm[1]] for mm in cluster] + doc.spans[f"{self.output_prefix}_{ii}"] = spans def update( self, @@ -421,7 +498,33 @@ def update( sgd: Optional[Optimizer] = None, losses: Optional[Dict[str, float]] = None, ) -> Dict[str, float]: - ... + """Learn from a batch of documents and gold-standard information, + updating the pipe's model. Delegates to predict and get_loss. + """ + if losses is None: + losses = {} + losses.setdefault(self.name, 0.0) + validate_examples(examples, "SpanPredictor.update") + if not any(len(eg.predicted) if eg.predicted else 0 for eg in examples): + # Handle cases where there are no tokens in any docs. + return losses + set_dropout_rate(self.model, drop) + + total_loss = 0 + + for eg in examples: + preds, backprop = self.model.begin_update([eg.predicted]) + score_matrix, mention_idx = preds + + loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) + total_loss += loss + # TODO check shape here + backprop((d_scores, mention_idx)) + + if sgd is not None: + self.finish_update(sgd) + losses[self.name] += total_loss + return losses def rehearse( self, @@ -431,7 +534,12 @@ def rehearse( sgd: Optional[Optimizer] = None, losses: Optional[Dict[str, float]] = None, ) -> Dict[str, float]: - ... + # TODO this should be added later + raise NotImplementedError( + Errors.E931.format( + parent="SpanPredictor", method="add_label", name=self.name + ) + ) def add_label(self, label: str) -> int: """Technically this method should be implemented from TrainablePipe, @@ -446,9 +554,39 @@ def add_label(self, label: str) -> int: def get_loss( self, examples: Iterable[Example], - # TODO add necessary args + span_scores: Floats3d, ): - ... + ops = self.model.ops + + # NOTE This is doing fake batching, and should always get a list of one example + assert len(examples) == 1, "Only fake batching is supported." + # starts and ends are gold starts and ends (Ints1d) + # span_scores is a Floats3d. What are the axes? mention x token x start/end + + for eg in examples: + + # get gold data + gold = doc2clusters(eg.reference, self.output_prefix) + # flatten the gold data + starts = [] + ends = [] + for cluster in gold: + for mention in cluster: + starts.append(mention[0]) + ends.append(mention[1]) + + start_scores = span_scores[:, :, 0] + end_scores = span_scores[:, :, 1] + n_classes = start_scores.shape[1] + start_probs = ops.softmax(start_scores, axis=1) + end_probs = ops.softmax(end_scores, axis=1) + start_targets = to_categorical(starts, n_classes) + end_targets = to_categorical(ends, n_classes) + start_grads = (start_probs - start_targets) + end_grads = (end_probs - end_targets) + grads = ops.xp.stack((start_grads, end_grads), axis=2) + loss = float((grads ** 2).sum()) + return loss, grads def initialize( self, @@ -461,6 +599,12 @@ def initialize( X = [] Y = [] for ex in islice(get_examples(), 2): + + if not ex.predicted.spans: + # set placeholder for shape inference + doc = ex.predicted + assert len(doc) > 2, "Coreference requires at least two tokens" + doc.spans[f"{self.input_prefix}_0"] = [doc[0:1], doc[1:2]] X.append(ex.predicted) Y.append(ex.reference) @@ -468,5 +612,31 @@ def initialize( self.model.initialize(X=X, Y=Y) def score(self, examples, **kwargs): - # TODO this will overlap significantly with coref, maybe factor into function - ... + """Score a batch of examples.""" + # TODO This is basically the same as the main coref component - factor out? + + scores = [] + for metric in (b_cubed, muc, ceafe): + evaluator = Evaluator(metric) + + for ex in examples: + # XXX this is the only different part + p_clusters = doc2clusters(ex.predicted, self.output_prefix) + g_clusters = doc2clusters(ex.reference, self.output_prefix) + + cluster_info = get_cluster_info(p_clusters, g_clusters) + + evaluator.update(cluster_info) + + score = { + "coref_f": evaluator.get_f1(), + "coref_p": evaluator.get_precision(), + "coref_r": evaluator.get_recall(), + } + scores.append(score) + + out = {} + for field in ("f", "p", "r"): + fname = f"coref_{field}" + out[fname] = mean([ss[fname] for ss in scores]) + return out From eec00ce60d83f500e18f2da7d9feafa7143440f2 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 23 Mar 2022 16:20:31 +0900 Subject: [PATCH 098/188] Fix various sizes in SpanPredictor FFNN --- spacy/ml/models/coref.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 382d7a98ba8..0f1614ef594 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -58,17 +58,6 @@ def build_wl_coref_model( ) coref_model = tok2vec >> coref_scorer - # XXX just ignore this until the coref scorer is integrated - span_predictor = PyTorchWrapper( - SpanPredictor( - # TODO this was hardcoded to 1024, check - hidden_size, - sp_embedding_size, - device - ), - - convert_inputs=convert_span_predictor_inputs - ) # TODO combine models so output is uniform (just one forward pass) # It may be reasonable to have an option to disable span prediction, # and just return words as spans. @@ -91,7 +80,7 @@ def build_span_predictor( # TODO fix device - should be automatic device = "cuda:0" span_predictor = PyTorchWrapper( - SpanPredictor(hidden_size, dist_emb_size, device), + SpanPredictor(dim, hidden_size, dist_emb_size, device), convert_inputs=convert_span_predictor_inputs ) # TODO use proper parameter for prefix @@ -512,23 +501,28 @@ def _prune(self, class SpanPredictor(torch.nn.Module): - def __init__(self, input_size: int, distance_emb_size: int, device): + def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int, device): super().__init__() + # input size = single token size + # 64 = probably distance emb size + # TODO check that dist_emb_size use is correct self.ffnn = torch.nn.Sequential( - torch.nn.Linear(input_size * 2 + 64, input_size), + torch.nn.Linear(input_size * 2 + dist_emb_size, hidden_size), torch.nn.ReLU(), torch.nn.Dropout(0.3), - torch.nn.Linear(input_size, 256), + #TODO seems weird the 256 isn't a parameter??? + torch.nn.Linear(hidden_size, 256), torch.nn.ReLU(), torch.nn.Dropout(0.3), - torch.nn.Linear(256, 64), + # this use of dist_emb_size looks wrong but it was 64...? + torch.nn.Linear(256, dist_emb_size), ) self.device = device self.conv = torch.nn.Sequential( torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) ) - self.emb = torch.nn.Embedding(128, distance_emb_size) # [-63, 63] + too_far + self.emb = torch.nn.Embedding(128, dist_emb_size) # [-63, 63] + too_far def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch sent_id, From 1eaf8fb0cf01dec6d6a01f20e109eb21fd5f530d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Wed, 23 Mar 2022 11:24:27 +0100 Subject: [PATCH 099/188] span predictor debug start --- spacy/ml/models/coref.py | 9 ++++----- spacy/pipeline/coref.py | 14 +++++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 382d7a98ba8..29f3ad81968 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -91,7 +91,7 @@ def build_span_predictor( # TODO fix device - should be automatic device = "cuda:0" span_predictor = PyTorchWrapper( - SpanPredictor(hidden_size, dist_emb_size, device), + SpanPredictor(dim, dist_emb_size, device), convert_inputs=convert_span_predictor_inputs ) # TODO use proper parameter for prefix @@ -148,7 +148,6 @@ def convert_span_predictor_inputs( # Normally we shoudl use the input is_train, but for these two it's not relevant sent_ids = xp2torch(sent_ids[0], requires_grad=False) head_ids = xp2torch(head_ids[0], requires_grad=False) - word_features = xp2torch(tok2vec[0], requires_grad=is_train) argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) @@ -557,7 +556,6 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in sent_id = torch.tensor(sent_id, device=words.device) heads_ids = heads_ids.long() same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) - # To save memory, only pass candidates from one sentence for each head # pair_matrix contains concatenated span_head_emb + candidate_emb + distance_emb # for each candidate among the words in the same sentence as span_head @@ -568,11 +566,11 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in words[cols], self.emb(emb_ids[rows, cols]), ), dim=1) - + input(len(heads_ids)) lengths = same_sent.sum(dim=1) padding_mask = torch.arange(0, lengths.max().item(), device=words.device).unsqueeze(0) padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] - + input(padding_mask.shape) # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] # This is necessary to allow the convolution layer to look at several # word scores @@ -592,6 +590,7 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in valid_positions = torch.stack((valid_starts, valid_ends), dim=2) return scores + valid_positions return scores + class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 54e9d8cfdc9..b3ced454ced 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -3,7 +3,7 @@ from thinc.types import Floats2d, Floats3d, Ints2d from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy -from thinc.api import set_dropout_rate +from thinc.api import set_dropout_rate, to_categorical from itertools import islice from statistics import mean @@ -513,10 +513,8 @@ def update( total_loss = 0 for eg in examples: - preds, backprop = self.model.begin_update([eg.predicted]) - score_matrix, mention_idx = preds - - loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) + span_scores, backprop = self.model.begin_update([eg.predicted]) + loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss # TODO check shape here backprop((d_scores, mention_idx)) @@ -573,8 +571,10 @@ def get_loss( for cluster in gold: for mention in cluster: starts.append(mention[0]) - ends.append(mention[1]) - + # XXX I think this was missing here + ends.append(mention[1] - 1) + starts = self.model.ops.xp.asarray(starts) + ends = self.model.ops.xp.asarray(ends) start_scores = span_scores[:, :, 0] end_scores = span_scores[:, :, 1] n_classes = start_scores.shape[1] From 706b2e6f25cc98e4be47adf5c0b8b968158019cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Thu, 24 Mar 2022 16:06:20 +0100 Subject: [PATCH 100/188] gearing up SpanPredictor for gold-heads --- spacy/ml/models/coref.py | 34 ++++++++++++++++++++-------------- spacy/pipeline/coref.py | 20 +++++++++++++++----- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 179de7e588e..3350a8dd9fd 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -53,7 +53,6 @@ def build_wl_coref_model( convert_inputs=convert_coref_scorer_inputs, convert_outputs=convert_coref_scorer_outputs ) - coref_model = tok2vec >> coref_scorer # XXX just ignore this until the coref scorer is integrated span_predictor = PyTorchWrapper( @@ -62,7 +61,6 @@ def build_wl_coref_model( hidden_size, sp_embedding_size, ), - convert_inputs=convert_span_predictor_inputs ) # TODO combine models so output is uniform (just one forward pass) @@ -84,14 +82,15 @@ def build_span_predictor( dim = 768 with Model.define_operators({">>": chain, "&": tuplify}): - # TODO fix device - should be automatic - device = "cuda:0" span_predictor = PyTorchWrapper( - SpanPredictor(dim, dist_emb_size, device), + SpanPredictor(dim, dist_emb_size), convert_inputs=convert_span_predictor_inputs ) # TODO use proper parameter for prefix - head_info = build_get_head_metadata("coref_head_clusters") + head_info = build_get_head_metadata( + "span_coref_head_clusters", + "coref_head_clusters" + ) model = (tok2vec & head_info) >> span_predictor return model @@ -148,7 +147,7 @@ def convert_span_predictor_inputs( argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) # TODO actually support backprop - return argskwargs, lambda dX: [] + return argskwargs, lambda dX: [[]] # TODO This probably belongs in the component, not the model. def predict_span_clusters(span_predictor: Model, @@ -217,18 +216,27 @@ def _clusterize( clusters.append(sorted(cluster)) return sorted(clusters) -def build_get_head_metadata(prefix): + +def build_get_head_metadata(update_prefix, predict_prefix): # TODO this name is awful, fix it - model = Model("HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward) + model = Model("HeadDataProvider", + attrs={ + "update_prefix": update_prefix, + "predict_prefix": predict_prefix + }, + forward=head_data_forward) return model + def head_data_forward(model, docs, is_train): """A layer to generate the extra data needed for the span predictor. """ sent_ids = [] head_ids = [] - prefix = model.attrs["prefix"] - + if is_train: + prefix = model.attrs["update_prefix"] + else: + prefix = model.attrs["predict_prefix"] for doc in docs: sids = model.ops.asarray2i(get_sentence_ids(doc)) sent_ids.append(sids) @@ -241,7 +249,7 @@ def head_data_forward(model, docs, is_train): heads.append(span[0].i) heads = model.ops.asarray2i(heads) head_ids.append(heads) - + # each of these is a list with one entry per doc # backprop is just a placeholder # TODO it would probably be better to have a list of tuples than two lists of arrays @@ -557,11 +565,9 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in words[cols], self.emb(emb_ids[rows, cols]), ), dim=1) - input(len(heads_ids)) lengths = same_sent.sum(dim=1) padding_mask = torch.arange(0, lengths.max().item(), device=words.device).unsqueeze(0) padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] - input(padding_mask.shape) # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] # This is necessary to allow the convolution layer to look at several # word scores diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index b3ced454ced..f37f777fc24 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -417,6 +417,7 @@ def score(self, examples, **kwargs): default_config={ "model": DEFAULT_SPAN_PREDICTOR_MODEL, "input_prefix": "coref_head_clusters", + "target_prefix": "span_head_target_clusters", "output_prefix": "coref_clusters", }, default_score_weights={"span_predictor_f": 1.0, "span_predictor_p": None, "span_predictor_r": None}, @@ -426,6 +427,7 @@ def make_span_predictor( name: str, model, input_prefix: str = "coref_head_clusters", + target_prefix: str = "span_head_target_clusters", output_prefix: str = "coref_clusters", ) -> "SpanPredictor": """Create a SpanPredictor component.""" @@ -444,12 +446,14 @@ def __init__( name: str = "span_predictor", *, input_prefix: str = "coref_head_clusters", + target_prefix: str = "span_coref_head_clusters", output_prefix: str = "coref_clusters", ) -> None: self.vocab = vocab self.model = model self.name = name self.input_prefix = input_prefix + self.target_prefix = target_prefix self.output_prefix = output_prefix self.cfg = {} @@ -511,13 +515,18 @@ def update( set_dropout_rate(self.model, drop) total_loss = 0 - - for eg in examples: - span_scores, backprop = self.model.begin_update([eg.predicted]) + docs = [eg.predicted for eg in examples] + for doc, eg in zip(docs, examples): + # replicates the EntityLinker's behaviour and + # copies annotations over https://bit.ly/3iweDcW + for key, sg in eg.reference.spans.items(): + if key.startswith(self.target_prefix): + doc.spans[key] = [doc[span.start:span.end] for span in sg] + span_scores, backprop = self.model.begin_update([doc]) loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss # TODO check shape here - backprop((d_scores, mention_idx)) + backprop(d_scores) if sgd is not None: self.finish_update(sgd) @@ -564,7 +573,7 @@ def get_loss( for eg in examples: # get gold data - gold = doc2clusters(eg.reference, self.output_prefix) + gold = doc2clusters(eg.predicted, self.target_prefix) # flatten the gold data starts = [] ends = [] @@ -605,6 +614,7 @@ def initialize( doc = ex.predicted assert len(doc) > 2, "Coreference requires at least two tokens" doc.spans[f"{self.input_prefix}_0"] = [doc[0:1], doc[1:2]] + doc.spans[f"{self.target_prefix}_0"] = [doc[0:1], doc[1:2]] X.append(ex.predicted) Y.append(ex.reference) From 1c5dabcb47f89635a9a5c529f48abaac694fcf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Thu, 24 Mar 2022 16:23:12 +0100 Subject: [PATCH 101/188] merge SpanPredictor attributes --- spacy/ml/models/coref.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 5fbb64a2984..5fe29c25fba 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -83,7 +83,7 @@ def build_span_predictor( with Model.define_operators({">>": chain, "&": tuplify}): span_predictor = PyTorchWrapper( - SpanPredictor(dim, dist_emb_size), + SpanPredictor(dim, hidden_size, dist_emb_size), convert_inputs=convert_span_predictor_inputs ) # TODO use proper parameter for prefix @@ -511,11 +511,7 @@ def _prune(self, class SpanPredictor(torch.nn.Module): -<<<<<<< HEAD - def __init__(self, input_size: int, distance_emb_size: int): -======= def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int, device): ->>>>>>> eec00ce60d83f500e18f2da7d9feafa7143440f2 super().__init__() # input size = single token size # 64 = probably distance emb size From 83ac0477c8e73b3676a8614368f430d3e9ae6fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Thu, 24 Mar 2022 16:44:50 +0100 Subject: [PATCH 102/188] remove useless extra prefix and device from spanpredictor --- spacy/ml/models/coref.py | 41 +++++++++++++++++----------------------- spacy/pipeline/coref.py | 10 +++------- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 5fe29c25fba..71082e7aceb 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -55,14 +55,14 @@ def build_wl_coref_model( ) coref_model = tok2vec >> coref_scorer # XXX just ignore this until the coref scorer is integrated - span_predictor = PyTorchWrapper( - SpanPredictor( - # TODO this was hardcoded to 1024, check - hidden_size, - sp_embedding_size, - ), - convert_inputs=convert_span_predictor_inputs - ) + # span_predictor = PyTorchWrapper( + # SpanPredictor( + # TODO this was hardcoded to 1024, check + # hidden_size, + # sp_embedding_size, + # ), + # convert_inputs=convert_span_predictor_inputs + # ) # TODO combine models so output is uniform (just one forward pass) # It may be reasonable to have an option to disable span prediction, # and just return words as spans. @@ -88,7 +88,6 @@ def build_span_predictor( ) # TODO use proper parameter for prefix head_info = build_get_head_metadata( - "span_coref_head_clusters", "coref_head_clusters" ) model = (tok2vec & head_info) >> span_predictor @@ -217,13 +216,10 @@ def _clusterize( return sorted(clusters) -def build_get_head_metadata(update_prefix, predict_prefix): +def build_get_head_metadata(prefix): # TODO this name is awful, fix it model = Model("HeadDataProvider", - attrs={ - "update_prefix": update_prefix, - "predict_prefix": predict_prefix - }, + attrs={'prefix': prefix}, forward=head_data_forward) return model @@ -233,10 +229,7 @@ def head_data_forward(model, docs, is_train): """ sent_ids = [] head_ids = [] - if is_train: - prefix = model.attrs["update_prefix"] - else: - prefix = model.attrs["predict_prefix"] + prefix = model.attrs["prefix"] for doc in docs: sids = model.ops.asarray2i(get_sentence_ids(doc)) sent_ids.append(sids) @@ -511,7 +504,7 @@ def _prune(self, class SpanPredictor(torch.nn.Module): - def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int, device): + def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): super().__init__() # input size = single token size # 64 = probably distance emb size @@ -551,13 +544,13 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in torch.Tensor: span start/end scores, [n_heads, n_words, 2] """ # Obtain distance embedding indices, [n_heads, n_words] - relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0], device=words.device).unsqueeze(0)) + relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0]).unsqueeze(0)) # make all valid distances positive emb_ids = relative_positions + 63 # "too_far" emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 # Obtain "same sentence" boolean mask, [n_heads, n_words] - sent_id = torch.tensor(sent_id, device=words.device) + sent_id = torch.tensor(sent_id) heads_ids = heads_ids.long() same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) # To save memory, only pass candidates from one sentence for each head @@ -571,18 +564,18 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in self.emb(emb_ids[rows, cols]), ), dim=1) lengths = same_sent.sum(dim=1) - padding_mask = torch.arange(0, lengths.max().item(), device=words.device).unsqueeze(0) + padding_mask = torch.arange(0, lengths.max().item()).unsqueeze(0) padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] # This is necessary to allow the convolution layer to look at several # word scores - padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1], device=words.device) + padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1]) padded_pairs[padding_mask] = pair_matrix res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] res = self.conv(res.permute(0, 2, 1)).permute(0, 2, 1) # [n_heads, n_candidates, 2] - scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf'), device=words.device) + scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf')) scores[rows, cols] = res[padding_mask] # Make sure that start <= head <= end during inference diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index f37f777fc24..eb05011ecba 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -417,7 +417,6 @@ def score(self, examples, **kwargs): default_config={ "model": DEFAULT_SPAN_PREDICTOR_MODEL, "input_prefix": "coref_head_clusters", - "target_prefix": "span_head_target_clusters", "output_prefix": "coref_clusters", }, default_score_weights={"span_predictor_f": 1.0, "span_predictor_p": None, "span_predictor_r": None}, @@ -427,7 +426,6 @@ def make_span_predictor( name: str, model, input_prefix: str = "coref_head_clusters", - target_prefix: str = "span_head_target_clusters", output_prefix: str = "coref_clusters", ) -> "SpanPredictor": """Create a SpanPredictor component.""" @@ -446,14 +444,12 @@ def __init__( name: str = "span_predictor", *, input_prefix: str = "coref_head_clusters", - target_prefix: str = "span_coref_head_clusters", output_prefix: str = "coref_clusters", ) -> None: self.vocab = vocab self.model = model self.name = name self.input_prefix = input_prefix - self.target_prefix = target_prefix self.output_prefix = output_prefix self.cfg = {} @@ -519,8 +515,9 @@ def update( for doc, eg in zip(docs, examples): # replicates the EntityLinker's behaviour and # copies annotations over https://bit.ly/3iweDcW + # takes 'coref_head_clusters' from the reference. for key, sg in eg.reference.spans.items(): - if key.startswith(self.target_prefix): + if key.startswith(self.input_prefix): doc.spans[key] = [doc[span.start:span.end] for span in sg] span_scores, backprop = self.model.begin_update([doc]) loss, d_scores = self.get_loss([eg], span_scores) @@ -573,7 +570,7 @@ def get_loss( for eg in examples: # get gold data - gold = doc2clusters(eg.predicted, self.target_prefix) + gold = doc2clusters(eg.predicted, self.input_prefix) # flatten the gold data starts = [] ends = [] @@ -614,7 +611,6 @@ def initialize( doc = ex.predicted assert len(doc) > 2, "Coreference requires at least two tokens" doc.spans[f"{self.input_prefix}_0"] = [doc[0:1], doc[1:2]] - doc.spans[f"{self.target_prefix}_0"] = [doc[0:1], doc[1:2]] X.append(ex.predicted) Y.append(ex.reference) From 7304604edd6238d16f156b3f30db40d809f1a440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Fri, 25 Mar 2022 18:29:33 +0100 Subject: [PATCH 103/188] make sure predicted and reference keeps aligned --- spacy/pipeline/coref.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index eb05011ecba..99bb611ff4d 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -130,7 +130,6 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: DOCS: https://spacy.io/api/coref#predict (TODO) """ - #print("DOCS", docs) out = [] for doc in docs: scores, idxs = self.model.predict([doc]) @@ -212,7 +211,6 @@ def update( # TODO check this causes no issues (in practice it runs) preds, backprop = self.model.begin_update([eg.predicted]) score_matrix, mention_idx = preds - loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) total_loss += loss # TODO check shape here @@ -518,7 +516,8 @@ def update( # takes 'coref_head_clusters' from the reference. for key, sg in eg.reference.spans.items(): if key.startswith(self.input_prefix): - doc.spans[key] = [doc[span.start:span.end] for span in sg] + aligned_spans = eg.get_aligned_spans_x2y(sg) + doc.spans[key] = [doc[span.start:span.end] for span in aligned_spans] span_scores, backprop = self.model.begin_update([doc]) loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss @@ -600,7 +599,7 @@ def initialize( *, nlp: Optional[Language] = None, ) -> None: - validate_get_examples(get_examples, "CoreferenceResolver.initialize") + validate_get_examples(get_examples, "SpanPredictor.initialize") X = [] Y = [] From 4fc40340f94d6dc47398dfa264804723b7e52b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 28 Mar 2022 11:28:21 +0200 Subject: [PATCH 104/188] handle empty head_ids --- spacy/ml/models/coref.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 71082e7aceb..7972f916020 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -133,6 +133,7 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: indices_xp = torch2xp(indices) return (scores_xp, indices_xp), convert_for_torch_backward + def convert_span_predictor_inputs( model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], @@ -141,13 +142,17 @@ def convert_span_predictor_inputs( tok2vec, (sent_ids, head_ids) = X # Normally we shoudl use the input is_train, but for these two it's not relevant sent_ids = xp2torch(sent_ids[0], requires_grad=False) - head_ids = xp2torch(head_ids[0], requires_grad=False) + if not head_ids[0].size: + head_ids = torch.empty(size=(0,)) + else: + head_ids = xp2torch(head_ids[0], requires_grad=False) word_features = xp2torch(tok2vec[0], requires_grad=is_train) argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) # TODO actually support backprop return argskwargs, lambda dX: [[]] + # TODO This probably belongs in the component, not the model. def predict_span_clusters(span_predictor: Model, sent_ids: Ints1d, @@ -543,6 +548,9 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in Returns: torch.Tensor: span start/end scores, [n_heads, n_words, 2] """ + # If we don't receive heads, return empty + if heads_ids.nelement() == 0: + return torch.empty(size=(0,)) # Obtain distance embedding indices, [n_heads, n_words] relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0]).unsqueeze(0)) # make all valid distances positive @@ -550,7 +558,6 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in # "too_far" emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 # Obtain "same sentence" boolean mask, [n_heads, n_words] - sent_id = torch.tensor(sent_id) heads_ids = heads_ids.long() same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) # To save memory, only pass candidates from one sentence for each head From e4b4b67ef6f627f7cd9cd313ab9274779c16c971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 28 Mar 2022 11:29:00 +0200 Subject: [PATCH 105/188] handle empty clusters --- spacy/pipeline/coref.py | 45 +++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 99bb611ff4d..5a4fa1ab919 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -458,27 +458,29 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: out = [] for doc in docs: # TODO check shape here - span_scores = self.model.predict(doc) - span_scores = span_scores[0] - # the information about clustering has to come from the input docs - # first let's convert the scores to a list of span idxs - start_scores = span_scores[:, :, 0] - end_scores = span_scores[:, :, 1] - starts = start_scores.argmax(axis=1) - ends = end_scores.argmax(axis=1) - - # TODO check start < end - - # get the old clusters (shape will be preserved) - clusters = doc2clusters(doc, self.input_prefix) - cidx = 0 - out_clusters = [] - for cluster in clusters: - ncluster = [] - for mention in cluster: - ncluster.append( (starts[cidx], ends[cidx]) ) - cidx += 1 - out_clusters.append(ncluster) + span_scores = self.model.predict([doc]) + if span_scores.size: + # the information about clustering has to come from the input docs + # first let's convert the scores to a list of span idxs + start_scores = span_scores[:, :, 0] + end_scores = span_scores[:, :, 1] + starts = start_scores.argmax(axis=1) + ends = end_scores.argmax(axis=1) + + # TODO check start < end + + # get the old clusters (shape will be preserved) + clusters = doc2clusters(doc, self.input_prefix) + cidx = 0 + out_clusters = [] + for cluster in clusters: + ncluster = [] + for mention in cluster: + ncluster.append((starts[cidx], ends[cidx])) + cidx += 1 + out_clusters.append(ncluster) + else: + out_clusters = [] out.append(out_clusters) return out @@ -628,7 +630,6 @@ def score(self, examples, **kwargs): # XXX this is the only different part p_clusters = doc2clusters(ex.predicted, self.output_prefix) g_clusters = doc2clusters(ex.reference, self.output_prefix) - cluster_info = get_cluster_info(p_clusters, g_clusters) evaluator.update(cluster_info) From 06d680b269c87059ca1fd0381f025a2bcc60c5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 28 Mar 2022 14:31:51 +0200 Subject: [PATCH 106/188] addressing suggestions by @polm --- spacy/pipeline/coref.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 5a4fa1ab919..340dde470cc 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -511,20 +511,24 @@ def update( set_dropout_rate(self.model, drop) total_loss = 0 - docs = [eg.predicted for eg in examples] - for doc, eg in zip(docs, examples): + old_spans = [eg.predicted.spans for eg in examples] + for eg in examples: # replicates the EntityLinker's behaviour and # copies annotations over https://bit.ly/3iweDcW - # takes 'coref_head_clusters' from the reference. + # https://github.com/explosion/spaCy/blob/master/spacy/pipeline/entity_linker.py#L313 + doc = eg.predicted for key, sg in eg.reference.spans.items(): if key.startswith(self.input_prefix): - aligned_spans = eg.get_aligned_spans_x2y(sg) - doc.spans[key] = [doc[span.start:span.end] for span in aligned_spans] + doc.spans[key] = eg.get_aligned_spans_y2x(sg) span_scores, backprop = self.model.begin_update([doc]) loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss # TODO check shape here backprop(d_scores) + # Restore examples + for spans, eg in zip(old_spans, examples): + for key, sg in spans.items(): + eg.predicted.spans[key] = sg if sgd is not None: self.finish_update(sgd) From 7ff99a3acc38cf7202fc269f32774d3e1f613d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 28 Mar 2022 18:16:41 +0200 Subject: [PATCH 107/188] nicer restore --- spacy/pipeline/coref.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 340dde470cc..f0862c844f7 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -364,9 +364,7 @@ def score(self, examples, **kwargs): for ex in examples: p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) - cluster_info = get_cluster_info(p_clusters, g_clusters) - evaluator.update(cluster_info) score = { @@ -511,12 +509,12 @@ def update( set_dropout_rate(self.model, drop) total_loss = 0 - old_spans = [eg.predicted.spans for eg in examples] for eg in examples: # replicates the EntityLinker's behaviour and # copies annotations over https://bit.ly/3iweDcW # https://github.com/explosion/spaCy/blob/master/spacy/pipeline/entity_linker.py#L313 doc = eg.predicted + old_spans = eg.predicted.spans for key, sg in eg.reference.spans.items(): if key.startswith(self.input_prefix): doc.spans[key] = eg.get_aligned_spans_y2x(sg) @@ -525,9 +523,8 @@ def update( total_loss += loss # TODO check shape here backprop(d_scores) - # Restore examples - for spans, eg in zip(old_spans, examples): - for key, sg in spans.items(): + # Restore example + for key, sg in old_spans.items(): eg.predicted.spans[key] = sg if sgd is not None: From 63a41ba50abd16c8b945bb39d8beff2879031cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Wed, 30 Mar 2022 17:28:20 +0200 Subject: [PATCH 108/188] fix score overwriting bug --- spacy/pipeline/coref.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index f0862c844f7..25a35340590 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -636,14 +636,14 @@ def score(self, examples, **kwargs): evaluator.update(cluster_info) score = { - "coref_f": evaluator.get_f1(), - "coref_p": evaluator.get_precision(), - "coref_r": evaluator.get_recall(), + "coref_span_f": evaluator.get_f1(), + "coref_span_p": evaluator.get_precision(), + "coref_span_r": evaluator.get_recall(), } scores.append(score) out = {} for field in ("f", "p", "r"): - fname = f"coref_{field}" + fname = f"coref_span_{field}" out[fname] = mean([ss[fname] for ss in scores]) return out From a1d021990379203a523c4c8683ce1bff620650f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 4 Apr 2022 15:26:15 +0200 Subject: [PATCH 109/188] prepare for aligned heads-spans training --- spacy/pipeline/coref.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 25a35340590..1c0e56521c1 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -503,29 +503,20 @@ def update( losses = {} losses.setdefault(self.name, 0.0) validate_examples(examples, "SpanPredictor.update") - if not any(len(eg.predicted) if eg.predicted else 0 for eg in examples): + if not any(len(eg.reference) if eg.reference else 0 for eg in examples): # Handle cases where there are no tokens in any docs. return losses set_dropout_rate(self.model, drop) total_loss = 0 for eg in examples: - # replicates the EntityLinker's behaviour and - # copies annotations over https://bit.ly/3iweDcW - # https://github.com/explosion/spaCy/blob/master/spacy/pipeline/entity_linker.py#L313 - doc = eg.predicted - old_spans = eg.predicted.spans - for key, sg in eg.reference.spans.items(): - if key.startswith(self.input_prefix): - doc.spans[key] = eg.get_aligned_spans_y2x(sg) - span_scores, backprop = self.model.begin_update([doc]) + # For update we use the gold coref_head_clusters + # in the reference. + span_scores, backprop = self.model.begin_update([eg.reference]) loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss # TODO check shape here backprop(d_scores) - # Restore example - for key, sg in old_spans.items(): - eg.predicted.spans[key] = sg if sgd is not None: self.finish_update(sgd) @@ -570,17 +561,14 @@ def get_loss( # span_scores is a Floats3d. What are the axes? mention x token x start/end for eg in examples: - - # get gold data - gold = doc2clusters(eg.predicted, self.input_prefix) - # flatten the gold data starts = [] ends = [] - for cluster in gold: - for mention in cluster: - starts.append(mention[0]) - # XXX I think this was missing here - ends.append(mention[1] - 1) + for key, sg in eg.reference.spans.items(): + if key.startswith(self.output_prefix): + for mention in sg: + starts.append(mention.start) + ends.append(mention.end) + starts = self.model.ops.xp.asarray(starts) ends = self.model.ops.xp.asarray(ends) start_scores = span_scores[:, :, 0] From ef141ad3995410d64cd27a615b3f17ee21d59dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 4 Apr 2022 18:10:09 +0200 Subject: [PATCH 110/188] span accuracy score --- spacy/pipeline/coref.py | 52 +++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 1c0e56521c1..c1db23d68b4 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -457,6 +457,7 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: for doc in docs: # TODO check shape here span_scores = self.model.predict([doc]) + print(span_scores) if span_scores.size: # the information about clustering has to come from the input docs # first let's convert the scores to a list of span idxs @@ -608,30 +609,35 @@ def initialize( self.model.initialize(X=X, Y=Y) def score(self, examples, **kwargs): - """Score a batch of examples.""" - # TODO This is basically the same as the main coref component - factor out? - + """ + Evaluate on reconstructing the correct spans around + gold heads. + """ scores = [] - for metric in (b_cubed, muc, ceafe): - evaluator = Evaluator(metric) - - for ex in examples: - # XXX this is the only different part - p_clusters = doc2clusters(ex.predicted, self.output_prefix) - g_clusters = doc2clusters(ex.reference, self.output_prefix) - cluster_info = get_cluster_info(p_clusters, g_clusters) - - evaluator.update(cluster_info) + for eg in examples: + starts = [] + ends = [] + pred_starts = [] + pred_ends = [] + ref = eg.reference + pred = eg.predicted + for key, gold_sg in ref.spans.items(): + if key.startswith(self.input_prefix): + cluster_id = key.split('_')[-1] + # FIXME THIS DOESN'T WORK BECAUSE pred.spans are empty? + pred_sg = pred.spans[f"{self.output_prefix}_{cluster_id}"] + for gold_mention, pred_mention in zip(gold_sg, pred_sg): + starts.append(gold_mention.start) + ends.append(gold_mention.end) + pred_starts.append(pred_mention.start) + pred_ends.append(pred_mention.end) - score = { - "coref_span_f": evaluator.get_f1(), - "coref_span_p": evaluator.get_precision(), - "coref_span_r": evaluator.get_recall(), - } - scores.append(score) + starts = self.model.ops.xp.asarray(starts) + ends = self.model.ops.xp.asarray(ends) + pred_starts = self.model.ops.xp.asarray(pred_starts) + pred_ends = self.model.ops.xp.asarray(pred_ends) + correct = ((starts == pred_starts) * (ends == pred_ends)).sum() + scores.append(correct) - out = {} - for field in ("f", "p", "r"): - fname = f"coref_span_{field}" - out[fname] = mean([ss[fname] for ss in scores]) + out = {"span_accuracy": mean(scores)} return out From 3ba913109d27827639eaa2bf91c1693bed7f33f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Thu, 7 Apr 2022 13:20:12 +0200 Subject: [PATCH 111/188] update with eg.predited as other components --- spacy/pipeline/coref.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index c1db23d68b4..1b062ed9a1b 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -457,7 +457,6 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: for doc in docs: # TODO check shape here span_scores = self.model.predict([doc]) - print(span_scores) if span_scores.size: # the information about clustering has to come from the input docs # first let's convert the scores to a list of span idxs @@ -513,7 +512,7 @@ def update( for eg in examples: # For update we use the gold coref_head_clusters # in the reference. - span_scores, backprop = self.model.begin_update([eg.reference]) + span_scores, backprop = self.model.begin_update([eg.predicted]) loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss # TODO check shape here @@ -622,10 +621,9 @@ def score(self, examples, **kwargs): ref = eg.reference pred = eg.predicted for key, gold_sg in ref.spans.items(): - if key.startswith(self.input_prefix): + if key.startswith(self.output_prefix): cluster_id = key.split('_')[-1] - # FIXME THIS DOESN'T WORK BECAUSE pred.spans are empty? - pred_sg = pred.spans[f"{self.output_prefix}_{cluster_id}"] + pred_sg = pred.spans[key] for gold_mention, pred_mention in zip(gold_sg, pred_sg): starts.append(gold_mention.start) ends.append(gold_mention.end) From 2a1ad4c5d294de02af668e07d19894491afc3204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Fri, 8 Apr 2022 14:56:44 +0200 Subject: [PATCH 112/188] add backprop callback to spanpredictor --- spacy/ml/models/coref.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 7972f916020..0b533daf092 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -104,13 +104,13 @@ def convert_coref_scorer_inputs( # just use the first # TODO real batching X = X[0] - - word_features = xp2torch(X, requires_grad=is_train) + def backprop(args: ArgsKwargs) -> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) return [gradients] + return ArgsKwargs(args=(word_features, ), kwargs={}), backprop @@ -141,16 +141,22 @@ def convert_span_predictor_inputs( ): tok2vec, (sent_ids, head_ids) = X # Normally we shoudl use the input is_train, but for these two it's not relevant + + def backprop(args: ArgsKwargs) -> List[Floats2d]: + # convert to xp and wrap in list + gradients = torch2xp(args.args[1]) + return [[gradients], None] + + word_features = xp2torch(tok2vec[0], requires_grad=is_train) sent_ids = xp2torch(sent_ids[0], requires_grad=False) if not head_ids[0].size: head_ids = torch.empty(size=(0,)) else: head_ids = xp2torch(head_ids[0], requires_grad=False) - word_features = xp2torch(tok2vec[0], requires_grad=is_train) argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) # TODO actually support backprop - return argskwargs, lambda dX: [[]] + return argskwargs, backprop # TODO This probably belongs in the component, not the model. @@ -247,7 +253,6 @@ def head_data_forward(model, docs, is_train): heads.append(span[0].i) heads = model.ops.asarray2i(heads) head_ids.append(heads) - # each of these is a list with one entry per doc # backprop is just a placeholder # TODO it would probably be better to have a list of tuples than two lists of arrays @@ -584,7 +589,6 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf')) scores[rows, cols] = res[padding_mask] - # Make sure that start <= head <= end during inference if not self.training: valid_starts = torch.log((relative_positions >= 0).to(torch.float)) From 7a239f2ec7c71a494f2380686fdbcfdd421e7fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Fri, 8 Apr 2022 14:57:19 +0200 Subject: [PATCH 113/188] report start- and end-accuracies separately --- spacy/pipeline/coref.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 1b062ed9a1b..02c93f71212 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -510,13 +510,11 @@ def update( total_loss = 0 for eg in examples: - # For update we use the gold coref_head_clusters - # in the reference. span_scores, backprop = self.model.begin_update([eg.predicted]) loss, d_scores = self.get_loss([eg], span_scores) total_loss += loss # TODO check shape here - backprop(d_scores) + backprop((d_scores)) if sgd is not None: self.finish_update(sgd) @@ -612,7 +610,8 @@ def score(self, examples, **kwargs): Evaluate on reconstructing the correct spans around gold heads. """ - scores = [] + start_scores = [] + end_scores = [] for eg in examples: starts = [] ends = [] @@ -622,7 +621,6 @@ def score(self, examples, **kwargs): pred = eg.predicted for key, gold_sg in ref.spans.items(): if key.startswith(self.output_prefix): - cluster_id = key.split('_')[-1] pred_sg = pred.spans[key] for gold_mention, pred_mention in zip(gold_sg, pred_sg): starts.append(gold_mention.start) @@ -634,8 +632,12 @@ def score(self, examples, **kwargs): ends = self.model.ops.xp.asarray(ends) pred_starts = self.model.ops.xp.asarray(pred_starts) pred_ends = self.model.ops.xp.asarray(pred_ends) - correct = ((starts == pred_starts) * (ends == pred_ends)).sum() - scores.append(correct) - - out = {"span_accuracy": mean(scores)} + start_accuracy = (starts == pred_starts).mean() + end_accuracy = (ends == pred_ends).mean() + start_scores.append(float(start_accuracy)) + end_scores.append(float(end_accuracy)) + out = { + "span_start_accuracy": mean(start_scores), + "span_end_accuracy": mean(end_scores) + } return out From 6aedd98d02b55672469556f4d61f2ad6254f3759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1d=C3=A1r=20=C3=81kos?= Date: Mon, 11 Apr 2022 16:10:14 +0200 Subject: [PATCH 114/188] fixing scorer --- spacy/pipeline/coref.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 02c93f71212..fc04d1a3ee5 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -511,10 +511,13 @@ def update( total_loss = 0 for eg in examples: span_scores, backprop = self.model.begin_update([eg.predicted]) - loss, d_scores = self.get_loss([eg], span_scores) - total_loss += loss - # TODO check shape here - backprop((d_scores)) + # FIXME, this only happens once in the first 1000 docs of OntoNotes + # and I'm not sure yet why. + if span_scores.size: + loss, d_scores = self.get_loss([eg], span_scores) + total_loss += loss + # TODO check shape here + backprop((d_scores)) if sgd is not None: self.finish_update(sgd) @@ -557,7 +560,6 @@ def get_loss( assert len(examples) == 1, "Only fake batching is supported." # starts and ends are gold starts and ends (Ints1d) # span_scores is a Floats3d. What are the axes? mention x token x start/end - for eg in examples: starts = [] ends = [] @@ -610,8 +612,8 @@ def score(self, examples, **kwargs): Evaluate on reconstructing the correct spans around gold heads. """ - start_scores = [] - end_scores = [] + scores = [] + xp = self.model.ops.xp for eg in examples: starts = [] ends = [] @@ -628,16 +630,11 @@ def score(self, examples, **kwargs): pred_starts.append(pred_mention.start) pred_ends.append(pred_mention.end) - starts = self.model.ops.xp.asarray(starts) - ends = self.model.ops.xp.asarray(ends) - pred_starts = self.model.ops.xp.asarray(pred_starts) - pred_ends = self.model.ops.xp.asarray(pred_ends) - start_accuracy = (starts == pred_starts).mean() - end_accuracy = (ends == pred_ends).mean() - start_scores.append(float(start_accuracy)) - end_scores.append(float(end_accuracy)) - out = { - "span_start_accuracy": mean(start_scores), - "span_end_accuracy": mean(end_scores) - } - return out + starts = xp.asarray(starts) + ends = xp.asarray(ends) + pred_starts = xp.asarray(pred_starts) + pred_ends = xp.asarray(pred_ends) + correct = (starts == pred_starts) * (ends == pred_ends) + accuracy = correct.mean() + scores.append(float(accuracy)) + return {"span_accuracy": mean(scores)} From b53113e3b82fbd72befcd10d019efd7bf38df0f2 Mon Sep 17 00:00:00 2001 From: kadarakos Date: Wed, 13 Apr 2022 12:42:49 +0200 Subject: [PATCH 115/188] Preparing span predictor for predicting from gold (#10547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note this is squashed because rebasing had conflicts. * remove unnecessary .device * span predictor debug start * gearing up SpanPredictor for gold-heads * merge SpanPredictor attributes * remove useless extra prefix and device from spanpredictor * make sure predicted and reference keeps aligned * handle empty head_ids * handle empty clusters * addressing suggestions by @polm * nicer restore * fix score overwriting bug * prepare for aligned heads-spans training * span accuracy score * update with eg.predited as other components * add backprop callback to spanpredictor * report start- and end-accuracies separately * fixing scorer Co-authored-by: Kádár Ákos --- spacy/ml/models/coref.py | 90 ++++++++++++------------ spacy/pipeline/coref.py | 144 +++++++++++++++++++-------------------- 2 files changed, 118 insertions(+), 116 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 0f1614ef594..0b533daf092 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -37,14 +37,11 @@ def build_wl_coref_model( except ValueError: # happens with transformer listener dim = 768 - + with Model.define_operators({">>": chain}): # TODO chain tok2vec with these models - # TODO fix device - should be automatic - device = "cuda:0" coref_scorer = PyTorchWrapper( CorefScorer( - device, dim, embedding_size, hidden_size, @@ -56,8 +53,16 @@ def build_wl_coref_model( convert_inputs=convert_coref_scorer_inputs, convert_outputs=convert_coref_scorer_outputs ) - coref_model = tok2vec >> coref_scorer + # XXX just ignore this until the coref scorer is integrated + # span_predictor = PyTorchWrapper( + # SpanPredictor( + # TODO this was hardcoded to 1024, check + # hidden_size, + # sp_embedding_size, + # ), + # convert_inputs=convert_span_predictor_inputs + # ) # TODO combine models so output is uniform (just one forward pass) # It may be reasonable to have an option to disable span prediction, # and just return words as spans. @@ -77,14 +82,14 @@ def build_span_predictor( dim = 768 with Model.define_operators({">>": chain, "&": tuplify}): - # TODO fix device - should be automatic - device = "cuda:0" span_predictor = PyTorchWrapper( - SpanPredictor(dim, hidden_size, dist_emb_size, device), + SpanPredictor(dim, hidden_size, dist_emb_size), convert_inputs=convert_span_predictor_inputs ) # TODO use proper parameter for prefix - head_info = build_get_head_metadata("coref_head_clusters") + head_info = build_get_head_metadata( + "coref_head_clusters" + ) model = (tok2vec & head_info) >> span_predictor return model @@ -99,13 +104,13 @@ def convert_coref_scorer_inputs( # just use the first # TODO real batching X = X[0] - - word_features = xp2torch(X, requires_grad=is_train) + def backprop(args: ArgsKwargs) -> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) return [gradients] + return ArgsKwargs(args=(word_features, ), kwargs={}), backprop @@ -128,6 +133,7 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: indices_xp = torch2xp(indices) return (scores_xp, indices_xp), convert_for_torch_backward + def convert_span_predictor_inputs( model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], @@ -135,14 +141,23 @@ def convert_span_predictor_inputs( ): tok2vec, (sent_ids, head_ids) = X # Normally we shoudl use the input is_train, but for these two it's not relevant - sent_ids = xp2torch(sent_ids[0], requires_grad=False) - head_ids = xp2torch(head_ids[0], requires_grad=False) + + def backprop(args: ArgsKwargs) -> List[Floats2d]: + # convert to xp and wrap in list + gradients = torch2xp(args.args[1]) + return [[gradients], None] word_features = xp2torch(tok2vec[0], requires_grad=is_train) + sent_ids = xp2torch(sent_ids[0], requires_grad=False) + if not head_ids[0].size: + head_ids = torch.empty(size=(0,)) + else: + head_ids = xp2torch(head_ids[0], requires_grad=False) argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) # TODO actually support backprop - return argskwargs, lambda dX: [] + return argskwargs, backprop + # TODO This probably belongs in the component, not the model. def predict_span_clusters(span_predictor: Model, @@ -211,18 +226,21 @@ def _clusterize( clusters.append(sorted(cluster)) return sorted(clusters) + def build_get_head_metadata(prefix): # TODO this name is awful, fix it - model = Model("HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward) + model = Model("HeadDataProvider", + attrs={'prefix': prefix}, + forward=head_data_forward) return model + def head_data_forward(model, docs, is_train): """A layer to generate the extra data needed for the span predictor. """ sent_ids = [] head_ids = [] prefix = model.attrs["prefix"] - for doc in docs: sids = model.ops.asarray2i(get_sentence_ids(doc)) sent_ids.append(sids) @@ -235,7 +253,6 @@ def head_data_forward(model, docs, is_train): heads.append(span[0].i) heads = model.ops.asarray2i(heads) head_ids.append(heads) - # each of these is a list with one entry per doc # backprop is just a placeholder # TODO it would probably be better to have a list of tuples than two lists of arrays @@ -256,7 +273,6 @@ class CorefScorer(torch.nn.Module): """ def __init__( self, - device: str, dim: int, # tok2vec size dist_emb_size: int, hidden_size: int, @@ -273,8 +289,7 @@ def __init__( epochs_trained (int): the number of epochs finished (useful for warm start) """ - # device, dist_emb_size, hidden_size, n_layers, dropout_rate - self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate).to(device) + self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate) #TODO clean this up bert_emb = dim pair_emb = bert_emb * 3 + self.pw.shape @@ -283,7 +298,7 @@ def __init__( hidden_size, n_layers, dropout_rate - ).to(device) + ) self.lstm = torch.nn.LSTM( input_size=bert_emb, hidden_size=bert_emb, @@ -294,7 +309,7 @@ def __init__( bert_emb, dropout_rate, roughk - ).to(device) + ) self.batch_size = batch_size def forward( @@ -443,7 +458,6 @@ def _get_pair_matrix(all_mentions: torch.Tensor, return out - class RoughScorer(torch.nn.Module): """ Is needed to give a roughly estimate of the anaphoricity of two candidates, @@ -474,7 +488,6 @@ def forward( pair_mask = torch.arange(mentions.shape[0]) pair_mask = pair_mask.unsqueeze(1) - pair_mask.unsqueeze(0) pair_mask = torch.log((pair_mask > 0).to(torch.float)) - pair_mask = pair_mask.to(mentions.device) bilinear_scores = self.dropout(self.bilinear(mentions)).mm(mentions.T) rough_scores = pair_mask + bilinear_scores @@ -501,7 +514,7 @@ def _prune(self, class SpanPredictor(torch.nn.Module): - def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int, device): + def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): super().__init__() # input size = single token size # 64 = probably distance emb size @@ -517,7 +530,6 @@ def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int, device # this use of dist_emb_size looks wrong but it was 64...? torch.nn.Linear(256, dist_emb_size), ) - self.device = device self.conv = torch.nn.Sequential( torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) @@ -541,17 +553,18 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in Returns: torch.Tensor: span start/end scores, [n_heads, n_words, 2] """ + # If we don't receive heads, return empty + if heads_ids.nelement() == 0: + return torch.empty(size=(0,)) # Obtain distance embedding indices, [n_heads, n_words] - relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0], device=words.device).unsqueeze(0)) + relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0]).unsqueeze(0)) # make all valid distances positive emb_ids = relative_positions + 63 # "too_far" emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 # Obtain "same sentence" boolean mask, [n_heads, n_words] - sent_id = torch.tensor(sent_id, device=words.device) heads_ids = heads_ids.long() same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) - # To save memory, only pass candidates from one sentence for each head # pair_matrix contains concatenated span_head_emb + candidate_emb + distance_emb # for each candidate among the words in the same sentence as span_head @@ -562,23 +575,20 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in words[cols], self.emb(emb_ids[rows, cols]), ), dim=1) - lengths = same_sent.sum(dim=1) - padding_mask = torch.arange(0, lengths.max().item(), device=words.device).unsqueeze(0) + padding_mask = torch.arange(0, lengths.max().item()).unsqueeze(0) padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] - # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] # This is necessary to allow the convolution layer to look at several # word scores - padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1], device=words.device) + padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1]) padded_pairs[padding_mask] = pair_matrix res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] res = self.conv(res.permute(0, 2, 1)).permute(0, 2, 1) # [n_heads, n_candidates, 2] - scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf'), device=words.device) + scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf')) scores[rows, cols] = res[padding_mask] - # Make sure that start <= head <= end during inference if not self.training: valid_starts = torch.log((relative_positions >= 0).to(torch.float)) @@ -586,6 +596,7 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in valid_positions = torch.stack((valid_starts, valid_ends), dim=2) return scores + valid_positions return scores + class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): @@ -595,17 +606,10 @@ def __init__(self, embedding_size, dropout_rate): self.dropout = torch.nn.Dropout(dropout_rate) self.shape = emb_size - @property - def device(self) -> torch.device: - """ A workaround to get current device (which is assumed to be the - device of the first parameter of one of the submodules) """ - return next(self.distance_emb.parameters()).device - - def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch top_indices: torch.Tensor ) -> torch.Tensor: - word_ids = torch.arange(0, top_indices.size(0), device=self.device) + word_ids = torch.arange(0, top_indices.size(0)) distance = (word_ids.unsqueeze(1) - word_ids[top_indices] ).clamp_min_(min=1) log_distance = distance.to(torch.float).log2().floor_() diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 54e9d8cfdc9..fc04d1a3ee5 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -3,7 +3,7 @@ from thinc.types import Floats2d, Floats3d, Ints2d from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy -from thinc.api import set_dropout_rate +from thinc.api import set_dropout_rate, to_categorical from itertools import islice from statistics import mean @@ -130,7 +130,6 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: DOCS: https://spacy.io/api/coref#predict (TODO) """ - #print("DOCS", docs) out = [] for doc in docs: scores, idxs = self.model.predict([doc]) @@ -212,7 +211,6 @@ def update( # TODO check this causes no issues (in practice it runs) preds, backprop = self.model.begin_update([eg.predicted]) score_matrix, mention_idx = preds - loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) total_loss += loss # TODO check shape here @@ -366,9 +364,7 @@ def score(self, examples, **kwargs): for ex in examples: p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) - cluster_info = get_cluster_info(p_clusters, g_clusters) - evaluator.update(cluster_info) score = { @@ -460,27 +456,29 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: out = [] for doc in docs: # TODO check shape here - span_scores = self.model.predict(doc) - span_scores = span_scores[0] - # the information about clustering has to come from the input docs - # first let's convert the scores to a list of span idxs - start_scores = span_scores[:, :, 0] - end_scores = span_scores[:, :, 1] - starts = start_scores.argmax(axis=1) - ends = end_scores.argmax(axis=1) - - # TODO check start < end - - # get the old clusters (shape will be preserved) - clusters = doc2clusters(doc, self.input_prefix) - cidx = 0 - out_clusters = [] - for cluster in clusters: - ncluster = [] - for mention in cluster: - ncluster.append( (starts[cidx], ends[cidx]) ) - cidx += 1 - out_clusters.append(ncluster) + span_scores = self.model.predict([doc]) + if span_scores.size: + # the information about clustering has to come from the input docs + # first let's convert the scores to a list of span idxs + start_scores = span_scores[:, :, 0] + end_scores = span_scores[:, :, 1] + starts = start_scores.argmax(axis=1) + ends = end_scores.argmax(axis=1) + + # TODO check start < end + + # get the old clusters (shape will be preserved) + clusters = doc2clusters(doc, self.input_prefix) + cidx = 0 + out_clusters = [] + for cluster in clusters: + ncluster = [] + for mention in cluster: + ncluster.append((starts[cidx], ends[cidx])) + cidx += 1 + out_clusters.append(ncluster) + else: + out_clusters = [] out.append(out_clusters) return out @@ -505,21 +503,21 @@ def update( losses = {} losses.setdefault(self.name, 0.0) validate_examples(examples, "SpanPredictor.update") - if not any(len(eg.predicted) if eg.predicted else 0 for eg in examples): + if not any(len(eg.reference) if eg.reference else 0 for eg in examples): # Handle cases where there are no tokens in any docs. return losses set_dropout_rate(self.model, drop) total_loss = 0 - for eg in examples: - preds, backprop = self.model.begin_update([eg.predicted]) - score_matrix, mention_idx = preds - - loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) - total_loss += loss - # TODO check shape here - backprop((d_scores, mention_idx)) + span_scores, backprop = self.model.begin_update([eg.predicted]) + # FIXME, this only happens once in the first 1000 docs of OntoNotes + # and I'm not sure yet why. + if span_scores.size: + loss, d_scores = self.get_loss([eg], span_scores) + total_loss += loss + # TODO check shape here + backprop((d_scores)) if sgd is not None: self.finish_update(sgd) @@ -562,19 +560,17 @@ def get_loss( assert len(examples) == 1, "Only fake batching is supported." # starts and ends are gold starts and ends (Ints1d) # span_scores is a Floats3d. What are the axes? mention x token x start/end - for eg in examples: - - # get gold data - gold = doc2clusters(eg.reference, self.output_prefix) - # flatten the gold data starts = [] ends = [] - for cluster in gold: - for mention in cluster: - starts.append(mention[0]) - ends.append(mention[1]) - + for key, sg in eg.reference.spans.items(): + if key.startswith(self.output_prefix): + for mention in sg: + starts.append(mention.start) + ends.append(mention.end) + + starts = self.model.ops.xp.asarray(starts) + ends = self.model.ops.xp.asarray(ends) start_scores = span_scores[:, :, 0] end_scores = span_scores[:, :, 1] n_classes = start_scores.shape[1] @@ -594,7 +590,7 @@ def initialize( *, nlp: Optional[Language] = None, ) -> None: - validate_get_examples(get_examples, "CoreferenceResolver.initialize") + validate_get_examples(get_examples, "SpanPredictor.initialize") X = [] Y = [] @@ -612,31 +608,33 @@ def initialize( self.model.initialize(X=X, Y=Y) def score(self, examples, **kwargs): - """Score a batch of examples.""" - # TODO This is basically the same as the main coref component - factor out? - + """ + Evaluate on reconstructing the correct spans around + gold heads. + """ scores = [] - for metric in (b_cubed, muc, ceafe): - evaluator = Evaluator(metric) - - for ex in examples: - # XXX this is the only different part - p_clusters = doc2clusters(ex.predicted, self.output_prefix) - g_clusters = doc2clusters(ex.reference, self.output_prefix) - - cluster_info = get_cluster_info(p_clusters, g_clusters) - - evaluator.update(cluster_info) - - score = { - "coref_f": evaluator.get_f1(), - "coref_p": evaluator.get_precision(), - "coref_r": evaluator.get_recall(), - } - scores.append(score) - - out = {} - for field in ("f", "p", "r"): - fname = f"coref_{field}" - out[fname] = mean([ss[fname] for ss in scores]) - return out + xp = self.model.ops.xp + for eg in examples: + starts = [] + ends = [] + pred_starts = [] + pred_ends = [] + ref = eg.reference + pred = eg.predicted + for key, gold_sg in ref.spans.items(): + if key.startswith(self.output_prefix): + pred_sg = pred.spans[key] + for gold_mention, pred_mention in zip(gold_sg, pred_sg): + starts.append(gold_mention.start) + ends.append(gold_mention.end) + pred_starts.append(pred_mention.start) + pred_ends.append(pred_mention.end) + + starts = xp.asarray(starts) + ends = xp.asarray(ends) + pred_starts = xp.asarray(pred_starts) + pred_ends = xp.asarray(pred_ends) + correct = (starts == pred_starts) * (ends == pred_ends) + accuracy = correct.mean() + scores.append(float(accuracy)) + return {"span_accuracy": mean(scores)} From d470fa03c15dadaf7bbc2fc71497aed9d5c9db8e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 13 Apr 2022 20:19:21 +0900 Subject: [PATCH 116/188] Adjust end indices It's not clear if this is technically correct or not but it won't run without it for me. --- spacy/pipeline/coref.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index fc04d1a3ee5..6c408d117ca 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -567,7 +567,9 @@ def get_loss( if key.startswith(self.output_prefix): for mention in sg: starts.append(mention.start) - ends.append(mention.end) + # TODO check: Is the -1 here correct? + # In Akos's env it works without, but in Paul's it doesn't. + ends.append(mention.end - 1) starts = self.model.ops.xp.asarray(starts) ends = self.model.ops.xp.asarray(ends) From 2300f4df3defd482b242dbefcd5c83e1dd987425 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 13 Apr 2022 20:37:06 +0900 Subject: [PATCH 117/188] Fix span score logging --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 6c408d117ca..4111f8445dd 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -415,7 +415,7 @@ def score(self, examples, **kwargs): "input_prefix": "coref_head_clusters", "output_prefix": "coref_clusters", }, - default_score_weights={"span_predictor_f": 1.0, "span_predictor_p": None, "span_predictor_r": None}, + default_score_weights={"span_accuracy": 1.0}, ) def make_span_predictor( nlp: Language, From e8af02700f610845a7caa53639120ea0987f6927 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 13 Apr 2022 21:02:18 +0900 Subject: [PATCH 118/188] Remove all coref scoring exept LEA This is necessary because one of the three old methods relied on scipy for some complex problem solving. LEA is generally better for evaluations. The downside is that this means evaluations aren't comparable with many papers, but canonical scoring can be supported using external eval scripts or other methods. --- spacy/coref_scorer.py | 128 +--------------------------------------- spacy/pipeline/coref.py | 52 ++++++++-------- 2 files changed, 25 insertions(+), 155 deletions(-) diff --git a/spacy/coref_scorer.py b/spacy/coref_scorer.py index e00b22fd709..b266ec3b32e 100644 --- a/spacy/coref_scorer.py +++ b/spacy/coref_scorer.py @@ -1,17 +1,5 @@ # copied from coval # https://github.com/ns-moosavi/coval -from collections import Counter -import numpy as np - -try: - # This is only used in the ceaf methods. If those are necessary we should - # implement this locally to avoid a scipy dep. - from scipy.optimize import linear_sum_assignment -except: - pass - -# Terminology here is consistent with papers in the field but kind of confusing. -# Key = gold data, System = predictions. def get_cluster_info(predicted_clusters, gold_clusters): @@ -44,29 +32,6 @@ def f1(p_num, p_den, r_num, r_den, beta=1): return 0 if p + r == 0 else (1 + beta * beta) * p * r / (beta * beta * p + r) -def evaluate_non_referrings(doc_non_referring_infos): - tp, _tn, fp, fn = 0, 0, 0, 0 - - for doc_id in doc_non_referring_infos: - key_non_referrings, sys_non_referrings = doc_non_referring_infos[doc_id] - for m in key_non_referrings: - if m in sys_non_referrings: - tp += 1 - else: - fn += 1 - for m in sys_non_referrings: - if m not in key_non_referrings: - fp += 1 - - recall = tp / float(tp + fn) if (tp + fn) > 0 else 0 - precision = tp / float(tp + fp) if (tp + fp) > 0 else 0 - f1 = ( - 2 * recall * precision / (recall + precision) if (recall + precision) > 0 else 0 - ) - - return recall, precision, f1 - - class Evaluator: def __init__(self, metric, beta=1, keep_aggregated_values=False): self.p_num = 0 @@ -91,14 +56,8 @@ def update(self, coref_info): sys_mention_key_cluster, ) = coref_info - if self.metric == ceafe or self.metric == ceafm: - pn, pd, rn, rd = self.metric(sys_clusters, key_clusters) - elif self.metric == lea: - pn, pd = self.metric(sys_clusters, key_clusters, sys_mention_key_cluster) - rn, rd = self.metric(key_clusters, sys_clusters, key_mention_sys_cluster) - else: - pn, pd = self.metric(sys_clusters, sys_mention_key_cluster) - rn, rd = self.metric(key_clusters, key_mention_sys_cluster) + pn, pd = self.metric(sys_clusters, key_clusters, sys_mention_key_cluster) + rn, rd = self.metric(key_clusters, sys_clusters, key_mention_sys_cluster) self.p_num += pn self.p_den += pd self.r_num += rn @@ -134,89 +93,6 @@ def get_aggregated_values(self): ) -def evaluate_documents(doc_coref_infos, metric, beta=1): - evaluator = Evaluator(metric, beta=beta) - for doc_id in doc_coref_infos: - evaluator.update(doc_coref_infos[doc_id]) - return (evaluator.get_recall(), evaluator.get_precision(), evaluator.get_f1()) - - -def get_document_evaluations(doc_coref_infos, metric, beta=1): - evaluator = Evaluator(metric, beta=beta, keep_aggregated_values=True) - for doc_id in doc_coref_infos: - evaluator.update(doc_coref_infos[doc_id]) - return evaluator.get_aggregated_values() - - -def mentions(clusters, mention_to_gold): - setofmentions = set(mention for cluster in clusters for mention in cluster) - correct = setofmentions & set(mention_to_gold.keys()) - return len(correct), len(setofmentions) - - -def b_cubed(clusters, mention_to_gold): - num, den = 0, 0 - - for c in clusters: - gold_counts = Counter() - correct = 0 - for m in c: - if m in mention_to_gold: - gold_counts[mention_to_gold[m]] += 1 - for c2 in gold_counts: - correct += gold_counts[c2] * gold_counts[c2] - - num += correct / float(len(c)) - den += len(c) - - return num, den - - -def muc(clusters, mention_to_gold): - tp, p = 0, 0 - for c in clusters: - p += len(c) - 1 - tp += len(c) - linked = set() - for m in c: - if m in mention_to_gold: - linked.add(mention_to_gold[m]) - else: - tp -= 1 - tp -= len(linked) - return tp, p - - -def phi4(c1, c2): - return 2 * len([m for m in c1 if m in c2]) / float(len(c1) + len(c2)) - - -def phi3(c1, c2): - return len([m for m in c1 if m in c2]) - - -def ceafe(clusters, gold_clusters): - clusters = [c for c in clusters] - scores = np.zeros((len(gold_clusters), len(clusters))) - for i in range(len(gold_clusters)): - for j in range(len(clusters)): - scores[i, j] = phi4(gold_clusters[i], clusters[j]) - row_ind, col_ind = linear_sum_assignment(-scores) - similarity = scores[row_ind, col_ind].sum() - return similarity, len(clusters), similarity, len(gold_clusters) - - -def ceafm(clusters, gold_clusters): - clusters = [c for c in clusters] - scores = np.zeros((len(gold_clusters), len(clusters))) - for i in range(len(gold_clusters)): - for j in range(len(clusters)): - scores[i, j] = phi3(gold_clusters[i], clusters[j]) - row_ind, col_ind = linear_sum_assignment(-scores) - similarity = scores[row_ind, col_ind].sum() - return similarity, len(clusters), similarity, len(gold_clusters) - - def lea(input_clusters, output_clusters, mention_to_gold): num, den = 0, 0 diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 4111f8445dd..671d65e19b7 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -25,7 +25,7 @@ doc2clusters, ) -from ..coref_scorer import Evaluator, get_cluster_info, b_cubed, muc, ceafe +from ..coref_scorer import Evaluator, get_cluster_info, lea default_config = """ @@ -349,36 +349,30 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) - # TODO This mirrors the evaluation used in prior work, but we don't want to - # include this in the final release. The metrics all have fundamental - # issues and the current implementation requires scipy. def score(self, examples, **kwargs): - """Score a batch of examples.""" + """Score a batch of examples using LEA. - # NOTE traditionally coref uses the average of b_cubed, muc, and ceaf. - # we need to handle the average ourselves. - scores = [] - for metric in (b_cubed, muc, ceafe): - evaluator = Evaluator(metric) - - for ex in examples: - p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) - g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) - cluster_info = get_cluster_info(p_clusters, g_clusters) - evaluator.update(cluster_info) - - score = { - "coref_f": evaluator.get_f1(), - "coref_p": evaluator.get_precision(), - "coref_r": evaluator.get_recall(), - } - scores.append(score) - - out = {} - for field in ("f", "p", "r"): - fname = f"coref_{field}" - out[fname] = mean([ss[fname] for ss in scores]) - return out + For details on how LEA works and why to use it see the paper: + + Which Coreference Evaluation Metric Do You Trust? A Proposal for a Link-based Entity Aware Metric + Moosavi and Strube, 2016 + https://api.semanticscholar.org/CorpusID:17606580 + """ + + evaluator = Evaluator(lea) + + for ex in examples: + p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) + g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) + cluster_info = get_cluster_info(p_clusters, g_clusters) + evaluator.update(cluster_info) + + score = { + "coref_f": evaluator.get_f1(), + "coref_p": evaluator.get_precision(), + "coref_r": evaluator.get_recall(), + } + return score default_span_predictor_config = """ From 8181d4570ca5d9273cdf8b653008e161dc6d8963 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 14 Apr 2022 15:56:38 +0900 Subject: [PATCH 119/188] Multiply accuracy by 100 This seems to match with the scorer expectations better --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 671d65e19b7..489f8875ba1 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -633,4 +633,4 @@ def score(self, examples, **kwargs): correct = (starts == pred_starts) * (ends == pred_ends) accuracy = correct.mean() scores.append(float(accuracy)) - return {"span_accuracy": mean(scores)} + return {"span_accuracy": 100 * mean(scores)} From 08729e0fbd46f2a595b8c14f4fef4dd4241b06e2 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 14 Apr 2022 18:31:30 +0900 Subject: [PATCH 120/188] Remove end adjustment The difference in environments was due to a change in Thinc, the code here is fine. --- spacy/pipeline/coref.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 489f8875ba1..ab315720f75 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -561,9 +561,7 @@ def get_loss( if key.startswith(self.output_prefix): for mention in sg: starts.append(mention.start) - # TODO check: Is the -1 here correct? - # In Akos's env it works without, but in Paul's it doesn't. - ends.append(mention.end - 1) + ends.append(mention.end) starts = self.model.ops.xp.asarray(starts) ends = self.model.ops.xp.asarray(ends) From afd255c0ed14eb183e9f5ab5dff6878e2309d55c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 14 Apr 2022 18:42:09 +0900 Subject: [PATCH 121/188] Undo multiply by 100 This was mistaken, not sure why my score seemed to be off before. --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index ab315720f75..98d411b0dde 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -631,4 +631,4 @@ def score(self, examples, **kwargs): correct = (starts == pred_starts) * (ends == pred_ends) accuracy = correct.mean() scores.append(float(accuracy)) - return {"span_accuracy": 100 * mean(scores)} + return {"span_accuracy": mean(scores)} From 6b51258a5848439e9eba663fec66f347825ca2a2 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 9 May 2022 13:34:50 +0200 Subject: [PATCH 122/188] clean up unused imports + black formatting --- spacy/ml/models/coref.py | 254 ++++++++++++++++++--------------------- 1 file changed, 115 insertions(+), 139 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 0b533daf092..835aeb1ce0c 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,27 +1,22 @@ -from dataclasses import dataclass -import warnings +from typing import List, Tuple +import torch -from thinc.api import Model, Linear, Relu, Dropout -from thinc.api import chain, noop, Embed, add, tuplify, concatenate -from thinc.api import reduce_first, reduce_last, reduce_mean +from thinc.api import Model, chain, tuplify from thinc.api import PyTorchWrapper, ArgsKwargs -from thinc.types import Floats2d, Floats1d, Ints1d, Ints2d, Ragged -from typing import List, Callable, Tuple, Any -from ...tokens import Doc -from ...util import registry -from ..extract_spans import extract_spans - -import torch +from thinc.types import Floats2d, Ints1d, Ints2d from thinc.util import xp2torch, torch2xp +from ...tokens import Doc +from ...util import registry from .coref_util import add_dummy, get_sentence_ids + @registry.architectures("spacy.Coref.v1") def build_wl_coref_model( tok2vec: Model[List[Doc], List[Floats2d]], embedding_size: int = 20, hidden_size: int = 1024, - n_hidden_layers: int = 1, # TODO rename to "depth"? + n_hidden_layers: int = 1, # TODO rename to "depth"? dropout: float = 0.3, # pairs to keep per mention after rough scoring # TODO change to meaningful name @@ -30,7 +25,7 @@ def build_wl_coref_model( a_scoring_batch_size: int = 512, # span predictor embeddings sp_embedding_size: int = 64, - ): +): # TODO fix this try: dim = tok2vec.get_dim("nO") @@ -48,10 +43,10 @@ def build_wl_coref_model( n_hidden_layers, dropout, rough_k, - a_scoring_batch_size + a_scoring_batch_size, ), convert_inputs=convert_coref_scorer_inputs, - convert_outputs=convert_coref_scorer_outputs + convert_outputs=convert_coref_scorer_outputs, ) coref_model = tok2vec >> coref_scorer # XXX just ignore this until the coref scorer is integrated @@ -68,12 +63,13 @@ def build_wl_coref_model( # and just return words as spans. return coref_model + @registry.architectures("spacy.SpanPredictor.v1") def build_span_predictor( tok2vec: Model[List[Doc], List[Floats2d]], hidden_size: int = 1024, dist_emb_size: int = 64, - ): +): # TODO fix this try: dim = tok2vec.get_dim("nO") @@ -84,22 +80,16 @@ def build_span_predictor( with Model.define_operators({">>": chain, "&": tuplify}): span_predictor = PyTorchWrapper( SpanPredictor(dim, hidden_size, dist_emb_size), - convert_inputs=convert_span_predictor_inputs + convert_inputs=convert_span_predictor_inputs, ) # TODO use proper parameter for prefix - head_info = build_get_head_metadata( - "coref_head_clusters" - ) + head_info = build_get_head_metadata("coref_head_clusters") model = (tok2vec & head_info) >> span_predictor return model -def convert_coref_scorer_inputs( - model: Model, - X: List[Floats2d], - is_train: bool -): +def convert_coref_scorer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc # just use the first # TODO real batching @@ -111,14 +101,10 @@ def backprop(args: ArgsKwargs) -> List[Floats2d]: gradients = torch2xp(args.args[0]) return [gradients] - return ArgsKwargs(args=(word_features, ), kwargs={}), backprop + return ArgsKwargs(args=(word_features,), kwargs={}), backprop -def convert_coref_scorer_outputs( - model: Model, - inputs_outputs, - is_train: bool -): +def convert_coref_scorer_outputs(model: Model, inputs_outputs, is_train: bool): _, outputs = inputs_outputs scores, indices = outputs @@ -135,9 +121,7 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: def convert_span_predictor_inputs( - model: Model, - X: Tuple[Ints1d, Floats2d, Ints1d], - is_train: bool + model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], is_train: bool ): tok2vec, (sent_ids, head_ids) = X # Normally we shoudl use the input is_train, but for these two it's not relevant @@ -160,10 +144,9 @@ def backprop(args: ArgsKwargs) -> List[Floats2d]: # TODO This probably belongs in the component, not the model. -def predict_span_clusters(span_predictor: Model, - sent_ids: Ints1d, - words: Floats2d, - clusters: List[Ints1d]): +def predict_span_clusters( + span_predictor: Model, sent_ids: Ints1d, words: Floats2d, clusters: List[Ints1d] +): """ Predicts span clusters based on the word clusters. @@ -187,20 +170,15 @@ def predict_span_clusters(span_predictor: Model, ends = (scores[:, :, 1].argmax(axis=1) + 1).tolist() head2span = { - head: (start, end) - for head, start, end in zip(heads_ids.tolist(), starts, ends) + head: (start, end) for head, start, end in zip(heads_ids.tolist(), starts, ends) } - return [[head2span[head] for head in cluster] - for cluster in clusters] + return [[head2span[head] for head in cluster] for cluster in clusters] + # TODO add docstring for this, maybe move to utils. # This might belong in the component. -def _clusterize( - model, - scores: Floats2d, - top_indices: Ints2d -): +def _clusterize(model, scores: Floats2d, top_indices: Ints2d): xp = model.ops.xp antecedents = scores.argmax(axis=1) - 1 not_dummy = antecedents >= 0 @@ -229,15 +207,14 @@ def _clusterize( def build_get_head_metadata(prefix): # TODO this name is awful, fix it - model = Model("HeadDataProvider", - attrs={'prefix': prefix}, - forward=head_data_forward) + model = Model( + "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward + ) return model def head_data_forward(model, docs, is_train): - """A layer to generate the extra data needed for the span predictor. - """ + """A layer to generate the extra data needed for the span predictor.""" sent_ids = [] head_ids = [] prefix = model.attrs["prefix"] @@ -271,15 +248,16 @@ class CorefScorer(torch.nn.Module): a_scorer (AnaphoricityScorer) sp (SpanPredictor) """ + def __init__( self, - dim: int, # tok2vec size + dim: int, # tok2vec size dist_emb_size: int, hidden_size: int, n_layers: int, dropout_rate: float, roughk: int, - batch_size: int + batch_size: int, ): super().__init__() """ @@ -290,14 +268,11 @@ def __init__( (useful for warm start) """ self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate) - #TODO clean this up + # TODO clean this up bert_emb = dim pair_emb = bert_emb * 3 + self.pw.shape self.a_scorer = AnaphoricityScorer( - pair_emb, - hidden_size, - n_layers, - dropout_rate + pair_emb, hidden_size, n_layers, dropout_rate ) self.lstm = torch.nn.LSTM( input_size=bert_emb, @@ -305,17 +280,10 @@ def __init__( batch_first=True, ) self.dropout = torch.nn.Dropout(dropout_rate) - self.rough_scorer = RoughScorer( - bert_emb, - dropout_rate, - roughk - ) + self.rough_scorer = RoughScorer(bert_emb, dropout_rate, roughk) self.batch_size = batch_size - def forward( - self, - word_features: torch.Tensor -) -> Tuple[torch.Tensor, torch.Tensor]: + def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ This is a massive method, but it made sense to me to not split it into several ones to let one see the data flow. @@ -327,7 +295,7 @@ def forward( """ # words [n_words, span_emb] # cluster_ids [n_words] - self.lstm.flatten_parameters() # XXX without this there's a warning + self.lstm.flatten_parameters() # XXX without this there's a warning word_features = torch.unsqueeze(word_features, dim=0) words, _ = self.lstm(word_features) words = words.squeeze() @@ -342,16 +310,18 @@ def forward( a_scores_lst: List[torch.Tensor] = [] for i in range(0, len(words), batch_size): - pw_batch = pw[i:i + batch_size] - words_batch = words[i:i + batch_size] - top_indices_batch = top_indices[i:i + batch_size] - top_rough_scores_batch = top_rough_scores[i:i + batch_size] + pw_batch = pw[i : i + batch_size] + words_batch = words[i : i + batch_size] + top_indices_batch = top_indices[i : i + batch_size] + top_rough_scores_batch = top_rough_scores[i : i + batch_size] # a_scores_batch [batch_size, n_ants] a_scores_batch = self.a_scorer( - all_mentions=words, mentions_batch=words_batch, - pw_batch=pw_batch, top_indices_batch=top_indices_batch, - top_rough_scores_batch=top_rough_scores_batch + all_mentions=words, + mentions_batch=words_batch, + pw_batch=pw_batch, + top_indices_batch=top_indices_batch, + top_rough_scores_batch=top_rough_scores_batch, ) a_scores_lst.append(a_scores_batch) @@ -360,33 +330,35 @@ def forward( class AnaphoricityScorer(torch.nn.Module): - """ Calculates anaphoricity scores by passing the inputs into a FFNN """ - def __init__(self, - in_features: int, - hidden_size, - n_hidden_layers, - dropout_rate): + """Calculates anaphoricity scores by passing the inputs into a FFNN""" + + def __init__(self, in_features: int, hidden_size, n_hidden_layers, dropout_rate): super().__init__() hidden_size = hidden_size if not n_hidden_layers: hidden_size = in_features layers = [] for i in range(n_hidden_layers): - layers.extend([torch.nn.Linear(hidden_size if i else in_features, - hidden_size), - torch.nn.LeakyReLU(), - torch.nn.Dropout(dropout_rate)]) + layers.extend( + [ + torch.nn.Linear(hidden_size if i else in_features, hidden_size), + torch.nn.LeakyReLU(), + torch.nn.Dropout(dropout_rate), + ] + ) self.hidden = torch.nn.Sequential(*layers) self.out = torch.nn.Linear(hidden_size, out_features=1) - def forward(self, *, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch - all_mentions: torch.Tensor, - mentions_batch: torch.Tensor, - pw_batch: torch.Tensor, - top_indices_batch: torch.Tensor, - top_rough_scores_batch: torch.Tensor, - ) -> torch.Tensor: - """ Builds a pairwise matrix, scores the pairs and returns the scores. + def forward( + self, + *, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + all_mentions: torch.Tensor, + mentions_batch: torch.Tensor, + pw_batch: torch.Tensor, + top_indices_batch: torch.Tensor, + top_rough_scores_batch: torch.Tensor, + ) -> torch.Tensor: + """Builds a pairwise matrix, scores the pairs and returns the scores. Args: all_mentions (torch.Tensor): [n_mentions, mention_emb] @@ -401,7 +373,8 @@ def forward(self, *, # type: ignore # pylint: disable=arguments-differ #35566 """ # [batch_size, n_ants, pair_emb] pair_matrix = self._get_pair_matrix( - all_mentions, mentions_batch, pw_batch, top_indices_batch) + all_mentions, mentions_batch, pw_batch, top_indices_batch + ) # [batch_size, n_ants] scores = top_rough_scores_batch + self._ffnn(pair_matrix) @@ -423,11 +396,12 @@ def _ffnn(self, x: torch.Tensor) -> torch.Tensor: return x.squeeze(2) @staticmethod - def _get_pair_matrix(all_mentions: torch.Tensor, - mentions_batch: torch.Tensor, - pw_batch: torch.Tensor, - top_indices_batch: torch.Tensor, - ) -> torch.Tensor: + def _get_pair_matrix( + all_mentions: torch.Tensor, + mentions_batch: torch.Tensor, + pw_batch: torch.Tensor, + top_indices_batch: torch.Tensor, + ) -> torch.Tensor: """ Builds the matrix used as input for AnaphoricityScorer. @@ -464,12 +438,8 @@ class RoughScorer(torch.nn.Module): only top scoring candidates are considered on later steps to reduce computational complexity. """ - def __init__( - self, - features: int, - dropout_rate: float, - rough_k: float - ): + + def __init__(self, features: int, dropout_rate: float, rough_k: float): super().__init__() self.dropout = torch.nn.Dropout(dropout_rate) self.bilinear = torch.nn.Linear(features, features) @@ -478,7 +448,7 @@ def __init__( def forward( self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch - mentions: torch.Tensor + mentions: torch.Tensor, ) -> Tuple[torch.Tensor, torch.Tensor]: """ Returns rough anaphoricity scores for candidates, which consist of @@ -493,9 +463,7 @@ def forward( return self._prune(rough_scores) - def _prune(self, - rough_scores: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + def _prune(self, rough_scores: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ Selects top-k rough antecedent scores for each mention. @@ -507,9 +475,9 @@ def _prune(self, FloatTensor of shape [n_mentions, k], top rough scores LongTensor of shape [n_mentions, k], top indices """ - top_scores, indices = torch.topk(rough_scores, - k=min(self.k, len(rough_scores)), - dim=1, sorted=False) + top_scores, indices = torch.topk( + rough_scores, k=min(self.k, len(rough_scores)), dim=1, sorted=False + ) return top_scores, indices @@ -523,7 +491,7 @@ def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): torch.nn.Linear(input_size * 2 + dist_emb_size, hidden_size), torch.nn.ReLU(), torch.nn.Dropout(0.3), - #TODO seems weird the 256 isn't a parameter??? + # TODO seems weird the 256 isn't a parameter??? torch.nn.Linear(hidden_size, 256), torch.nn.ReLU(), torch.nn.Dropout(0.3), @@ -531,15 +499,16 @@ def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): torch.nn.Linear(256, dist_emb_size), ) self.conv = torch.nn.Sequential( - torch.nn.Conv1d(64, 4, 3, 1, 1), - torch.nn.Conv1d(4, 2, 3, 1, 1) + torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) ) - self.emb = torch.nn.Embedding(128, dist_emb_size) # [-63, 63] + too_far + self.emb = torch.nn.Embedding(128, dist_emb_size) # [-63, 63] + too_far - def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch - sent_id, - words: torch.Tensor, - heads_ids: torch.Tensor) -> torch.Tensor: + def forward( + self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + sent_id, + words: torch.Tensor, + heads_ids: torch.Tensor, + ) -> torch.Tensor: """ Calculates span start/end scores of words for each span head in heads_ids @@ -557,37 +526,44 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in if heads_ids.nelement() == 0: return torch.empty(size=(0,)) # Obtain distance embedding indices, [n_heads, n_words] - relative_positions = (heads_ids.unsqueeze(1) - torch.arange(words.shape[0]).unsqueeze(0)) + relative_positions = heads_ids.unsqueeze(1) - torch.arange( + words.shape[0] + ).unsqueeze(0) # make all valid distances positive emb_ids = relative_positions + 63 # "too_far" emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 # Obtain "same sentence" boolean mask, [n_heads, n_words] heads_ids = heads_ids.long() - same_sent = (sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0)) + same_sent = sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0) # To save memory, only pass candidates from one sentence for each head # pair_matrix contains concatenated span_head_emb + candidate_emb + distance_emb # for each candidate among the words in the same sentence as span_head # [n_heads, input_size * 2 + distance_emb_size] rows, cols = same_sent.nonzero(as_tuple=True) - pair_matrix = torch.cat(( - words[heads_ids[rows]], - words[cols], - self.emb(emb_ids[rows, cols]), - ), dim=1) + pair_matrix = torch.cat( + ( + words[heads_ids[rows]], + words[cols], + self.emb(emb_ids[rows, cols]), + ), + dim=1, + ) lengths = same_sent.sum(dim=1) padding_mask = torch.arange(0, lengths.max().item()).unsqueeze(0) - padding_mask = (padding_mask < lengths.unsqueeze(1)) # [n_heads, max_sent_len] + padding_mask = padding_mask < lengths.unsqueeze(1) # [n_heads, max_sent_len] # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] # This is necessary to allow the convolution layer to look at several # word scores padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1]) padded_pairs[padding_mask] = pair_matrix - res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] - res = self.conv(res.permute(0, 2, 1)).permute(0, 2, 1) # [n_heads, n_candidates, 2] + res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] + res = self.conv(res.permute(0, 2, 1)).permute( + 0, 2, 1 + ) # [n_heads, n_candidates, 2] - scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float('-inf')) + scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float("-inf")) scores[rows, cols] = res[padding_mask] # Make sure that start <= head <= end during inference if not self.training: @@ -597,8 +573,8 @@ def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in return scores + valid_positions return scores -class DistancePairwiseEncoder(torch.nn.Module): +class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): super().__init__() emb_size = embedding_size @@ -606,12 +582,12 @@ def __init__(self, embedding_size, dropout_rate): self.dropout = torch.nn.Dropout(dropout_rate) self.shape = emb_size - def forward(self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch - top_indices: torch.Tensor - ) -> torch.Tensor: + def forward( + self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + top_indices: torch.Tensor, + ) -> torch.Tensor: word_ids = torch.arange(0, top_indices.size(0)) - distance = (word_ids.unsqueeze(1) - word_ids[top_indices] - ).clamp_min_(min=1) + distance = (word_ids.unsqueeze(1) - word_ids[top_indices]).clamp_min_(min=1) log_distance = distance.to(torch.float).log2().floor_() log_distance = log_distance.clamp_max_(max=6).to(torch.long) distance = torch.where(distance < 5, distance - 1, log_distance + 2) From 117a9ef2bf4cf22ca6e5d1997a5711af587146e0 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 10 May 2022 18:33:25 +0900 Subject: [PATCH 123/188] Initial coref docs A few unresolved points: - SpanPredictor should probably get its own file - What's the right way to document MentionClusters? --- website/docs/api/coref.md | 338 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 website/docs/api/coref.md diff --git a/website/docs/api/coref.md b/website/docs/api/coref.md new file mode 100644 index 00000000000..53ed6a4c8ed --- /dev/null +++ b/website/docs/api/coref.md @@ -0,0 +1,338 @@ +--- +title: CoreferenceResolver +tag: class +source: spacy/pipeline/coref.py +new: 3.4 +teaser: 'Pipeline component for word-level coreference resolution' +api_base_class: /api/pipe +api_string_name: coref +api_trainable: true +--- + +A `CoreferenceResolver` component groups tokens into clusters that refer to the +same thing. Clusters are represented as SpanGroups that start with a prefix +(`coref_clusters_` by default). + +A `CoreferenceResolver` component can be paired with a +[`SpanPredictor`](/api/spanpredictor) to expand single tokens to spans. + +## Assigned Attributes {#assigned-attributes} + +Predictions will be saved to `Doc.spans` as a [`SpanGroup`](/api/spangroup). The +span key will be a prefix plus a serial number referring to the coreference +cluster, starting from zero. + +The span key prefix defaults to `"coreference_clusters"`, but can be passed as a +parameter. + +| Location | Value | +| ------------------------------------------ | ------------------------------------------------------------------------- | +| `Doc.spans[prefix + "_" + cluster_number]` | One coreference cluster, represented as single-token spans. ~~SpanGroup~~ | + +## Config and implementation {#config} + +The default config is defined by the pipeline component factory and describes +how the component should be configured. You can override its settings via the +`config` argument on [`nlp.add_pipe`](/api/language#add_pipe) or in your +[`config.cfg` for training](/usage/training#config). See the +[model architectures](/api/architectures) documentation for details on the +architectures and their arguments and hyperparameters. + +> #### Example +> +> ```python +> from spacy.pipeline.coref import DEFAULT_COREF_MODEL +> config={ +> "model": DEFAULT_COREF_MODEL, +> "span_cluster_prefix": DEFAULT_CLUSTER_PREFIX, +> }, +> nlp.add_pipe("coref", config=config) +> ``` + +| Setting | Description | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `model` | The [`Model`](https://thinc.ai/docs/api-model) powering the pipeline component. Defaults to [Coref](/api/architectures#Coref). ~~Model~~ | +| `span_cluster_prefix` | The prefix for the keys for clusters saved to `doc.spans`. Defaults to `coref_clusters`. ~~str~~ | + +```python +%%GITHUB_SPACY/spacy/pipeline/coref.py +``` + +## CoreferenceResolver.\_\_init\_\_ {#init tag="method"} + +> #### Example +> +> ```python +> # Construction via add_pipe with default model +> coref = nlp.add_pipe("coref") +> +> # Construction via add_pipe with custom model +> config = {"model": {"@architectures": "my_coref.v1"}} +> coref = nlp.add_pipe("coref", config=config) +> +> # Construction from class +> from spacy.pipeline import CoreferenceResolver +> coref = CoreferenceResolver(nlp.vocab, model) +> ``` + +Create a new pipeline instance. In your application, you would normally use a +shortcut for this and instantiate the component using its string name and +[`nlp.add_pipe`](/api/language#add_pipe). + +| Name | Description | +| --------------------- | --------------------------------------------------------------------------------------------------- | +| `vocab` | The shared vocabulary. ~~Vocab~~ | +| `model` | The [`Model`](https://thinc.ai/docs/api-model) powering the pipeline component. ~~Model~~ | +| `name` | String name of the component instance. Used to add entries to the `losses` during training. ~~str~~ | +| _keyword-only_ | | +| `span_cluster_prefix` | The prefix for the key for saving clusters of spans. ~~bool~~ | + +## CoreferenceResolver.\_\_call\_\_ {#call tag="method"} + +Apply the pipe to one document. The document is modified in place and returned. +This usually happens under the hood when the `nlp` object is called on a text +and all pipeline components are applied to the `Doc` in order. Both +[`__call__`](/api/entitylinker#call) and [`pipe`](/api/entitylinker#pipe) +delegate to the [`predict`](/api/entitylinker#predict) and +[`set_annotations`](/api/entitylinker#set_annotations) methods. + +> #### Example +> +> ```python +> doc = nlp("This is a sentence.") +> coref = nlp.add_pipe("coref") +> # This usually happens under the hood +> processed = coref(doc) +> ``` + +| Name | Description | +| ----------- | -------------------------------- | +| `doc` | The document to process. ~~Doc~~ | +| **RETURNS** | The processed document. ~~Doc~~ | + +## CoreferenceResolver.pipe {#pipe tag="method"} + +Apply the pipe to a stream of documents. This usually happens under the hood +when the `nlp` object is called on a text and all pipeline components are +applied to the `Doc` in order. Both [`__call__`](/api/coref#call) and +[`pipe`](/api/coref#pipe) delegate to the [`predict`](/api/coref#predict) and +[`set_annotations`](/api/coref#set_annotations) methods. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> for doc in coref.pipe(docs, batch_size=50): +> pass +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------- | +| `stream` | A stream of documents. ~~Iterable[Doc]~~ | +| _keyword-only_ | | +| `batch_size` | The number of documents to buffer. Defaults to `128`. ~~int~~ | +| **YIELDS** | The processed documents in order. ~~Doc~~ | + +## CoreferenceResolver.initialize {#initialize tag="method"} + +Initialize the component for training. `get_examples` should be a function that +returns an iterable of [`Example`](/api/example) objects. The data examples are +used to **initialize the model** of the component and can either be the full +training data or a representative sample. Initialization includes validating the +network, +[inferring missing shapes](https://thinc.ai/docs/usage-models#validation) and +setting up the label scheme based on the data. This method is typically called +by [`Language.initialize`](/api/language#initialize). + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> coref.initialize(lambda: [], nlp=nlp) +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `get_examples` | Function that returns gold-standard annotations in the form of [`Example`](/api/example) objects. ~~Callable[[], Iterable[Example]]~~ | +| _keyword-only_ | | +| `nlp` | The current `nlp` object. Defaults to `None`. ~~Optional[Language]~~ | + +## CoreferenceResolver.predict {#predict tag="method"} + +Apply the component's model to a batch of [`Doc`](/api/doc) objects, without +modifying them. Clusters are returned as a list of `MentionClusters`, one for +each input `Doc`. A `MentionClusters` instance is just a list of lists of pairs +of `int`s, where each item corresponds to a cluster, and the `int`s correspond +to token indices. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> clusters = coref.predict([doc1, doc2]) +> ``` + +| Name | Description | +| ----------- | ---------------------------------------------------------------------------- | +| `docs` | The documents to predict. ~~Iterable[Doc]~~ | +| **RETURNS** | The predicted coreference clusters for the `docs`. ~~List[MentionClusters]~~ | + +## CoreferenceResolver.set_annotations {#set_annotations tag="method"} + +Modify a batch of documents, saving coreference clusters in `Doc.spans`. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> clusters = coref.predict([doc1, doc2]) +> coref.set_annotations([doc1, doc2], clusters) +> ``` + +| Name | Description | +| ---------- | ---------------------------------------------------------------------------- | +| `docs` | The documents to modify. ~~Iterable[Doc]~~ | +| `clusters` | The predicted coreference clusters for the `docs`. ~~List[MentionClusters]~~ | + +## CoreferenceResolver.update {#update tag="method"} + +Learn from a batch of [`Example`](/api/example) objects. Delegates to +[`predict`](/api/entitylinker#predict). + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> optimizer = nlp.initialize() +> losses = coref.update(examples, sgd=optimizer) +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `examples` | A batch of [`Example`](/api/example) objects to learn from. ~~Iterable[Example]~~ | +| _keyword-only_ | | +| `drop` | The dropout rate. ~~float~~ | +| `sgd` | An optimizer. Will be created via [`create_optimizer`](#create_optimizer) if not set. ~~Optional[Optimizer]~~ | +| `losses` | Optional record of the loss during training. Updated using the component name as the key. ~~Optional[Dict[str, float]]~~ | +| **RETURNS** | The updated `losses` dictionary. ~~Dict[str, float]~~ | + +## CoreferenceResolver.create_optimizer {#create_optimizer tag="method"} + +Create an optimizer for the pipeline component. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> optimizer = coref.create_optimizer() +> ``` + +| Name | Description | +| ----------- | ---------------------------- | +| **RETURNS** | The optimizer. ~~Optimizer~~ | + +## CoreferenceResolver.use_params {#use_params tag="method, contextmanager"} + +Modify the pipe's model, to use the given parameter values. At the end of the +context, the original parameters are restored. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> with coref.use_params(optimizer.averages): +> coref.to_disk("/best_model") +> ``` + +| Name | Description | +| -------- | -------------------------------------------------- | +| `params` | The parameter values to use in the model. ~~dict~~ | + +## CoreferenceResolver.to_disk {#to_disk tag="method"} + +Serialize the pipe to disk. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> coref.to_disk("/path/to/coref") +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `path` | A path to a directory, which will be created if it doesn't exist. Paths may be either strings or `Path`-like objects. ~~Union[str, Path]~~ | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | + +## CoreferenceResolver.from_disk {#from_disk tag="method"} + +Load the pipe from disk. Modifies the object in place and returns it. + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> coref.from_disk("/path/to/coref") +> ``` + +| Name | Description | +| -------------- | ----------------------------------------------------------------------------------------------- | +| `path` | A path to a directory. Paths may be either strings or `Path`-like objects. ~~Union[str, Path]~~ | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | +| **RETURNS** | The modified `CoreferenceResolver` object. ~~CoreferenceResolver~~ | + +## CoreferenceResolver.to_bytes {#to_bytes tag="method"} + +> #### Example +> +> ```python +> coref = nlp.add_pipe("coref") +> coref_bytes = coref.to_bytes() +> ``` + +Serialize the pipe to a bytestring, including the `KnowledgeBase`. + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------- | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | +| **RETURNS** | The serialized form of the `CoreferenceResolver` object. ~~bytes~~ | + +## CoreferenceResolver.from_bytes {#from_bytes tag="method"} + +Load the pipe from a bytestring. Modifies the object in place and returns it. + +> #### Example +> +> ```python +> coref_bytes = coref.to_bytes() +> coref = nlp.add_pipe("coref") +> coref.from_bytes(coref_bytes) +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------- | +| `bytes_data` | The data to load from. ~~bytes~~ | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | +| **RETURNS** | The `CoreferenceResolver` object. ~~CoreferenceResolver~~ | + +## Serialization fields {#serialization-fields} + +During serialization, spaCy will export several data fields used to restore +different aspects of the object. If needed, you can exclude them from +serialization by passing in the string names via the `exclude` argument. + +> #### Example +> +> ```python +> data = coref.to_disk("/path", exclude=["vocab"]) +> ``` + +| Name | Description | +| ------- | -------------------------------------------------------------- | +| `vocab` | The shared [`Vocab`](/api/vocab). | +| `cfg` | The config file. You usually don't want to exclude this. | +| `model` | The binary model data. You usually don't want to exclude this. | From f852c5cea401aeecba8661c14480462d803dd26a Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 10 May 2022 18:53:45 +0900 Subject: [PATCH 124/188] Split span predictor component into its own file This runs. The imports in both of the split files could probably use a close check to remove extras. --- spacy/pipeline/__init__.py | 1 + spacy/pipeline/coref.py | 261 +--------------------------- spacy/pipeline/span_predictor.py | 280 +++++++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 259 deletions(-) create mode 100644 spacy/pipeline/span_predictor.py diff --git a/spacy/pipeline/__init__.py b/spacy/pipeline/__init__.py index d39b3170fc8..7ca088cce35 100644 --- a/spacy/pipeline/__init__.py +++ b/spacy/pipeline/__init__.py @@ -1,5 +1,6 @@ from .attributeruler import AttributeRuler from .coref import CoreferenceResolver +from .span_predictor import SpanPredictor from .dep_parser import DependencyParser from .edit_tree_lemmatizer import EditTreeLemmatizer from .entity_linker import EntityLinker diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 98d411b0dde..86a9d9e2cc2 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -56,7 +56,7 @@ maxout_pieces = 3 depth = 2 """ -DEFAULT_MODEL = Config().from_str(default_config)["model"] +DEFAULT_COREF_MODEL = Config().from_str(default_config)["model"] DEFAULT_CLUSTERS_PREFIX = "coref_clusters" @@ -66,7 +66,7 @@ assigns=["doc.spans"], requires=["doc.spans"], default_config={ - "model": DEFAULT_MODEL, + "model": DEFAULT_COREF_MODEL, "span_cluster_prefix": DEFAULT_CLUSTER_PREFIX, }, default_score_weights={"coref_f": 1.0, "coref_p": None, "coref_r": None}, @@ -375,260 +375,3 @@ def score(self, examples, **kwargs): return score -default_span_predictor_config = """ -[model] -@architectures = "spacy.SpanPredictor.v1" -hidden_size = 1024 -dist_emb_size = 64 - -[model.tok2vec] -@architectures = "spacy.Tok2Vec.v2" - -[model.tok2vec.embed] -@architectures = "spacy.MultiHashEmbed.v1" -width = 64 -rows = [2000, 2000, 1000, 1000, 1000, 1000] -attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] -include_static_vectors = false - -[model.tok2vec.encode] -@architectures = "spacy.MaxoutWindowEncoder.v2" -width = ${model.tok2vec.embed.width} -window_size = 1 -maxout_pieces = 3 -depth = 2 -""" -DEFAULT_SPAN_PREDICTOR_MODEL = Config().from_str(default_span_predictor_config)["model"] - -@Language.factory( - "span_predictor", - assigns=["doc.spans"], - requires=["doc.spans"], - default_config={ - "model": DEFAULT_SPAN_PREDICTOR_MODEL, - "input_prefix": "coref_head_clusters", - "output_prefix": "coref_clusters", - }, - default_score_weights={"span_accuracy": 1.0}, - ) -def make_span_predictor( - nlp: Language, - name: str, - model, - input_prefix: str = "coref_head_clusters", - output_prefix: str = "coref_clusters", -) -> "SpanPredictor": - """Create a SpanPredictor component.""" - return SpanPredictor(nlp.vocab, model, name, input_prefix=input_prefix, output_prefix=output_prefix) - -class SpanPredictor(TrainablePipe): - """Pipeline component to resolve one-token spans to full spans. - - Used in coreference resolution. - """ - - def __init__( - self, - vocab: Vocab, - model: Model, - name: str = "span_predictor", - *, - input_prefix: str = "coref_head_clusters", - output_prefix: str = "coref_clusters", - ) -> None: - self.vocab = vocab - self.model = model - self.name = name - self.input_prefix = input_prefix - self.output_prefix = output_prefix - - self.cfg = {} - - def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: - # for now pretend there's just one doc - - out = [] - for doc in docs: - # TODO check shape here - span_scores = self.model.predict([doc]) - if span_scores.size: - # the information about clustering has to come from the input docs - # first let's convert the scores to a list of span idxs - start_scores = span_scores[:, :, 0] - end_scores = span_scores[:, :, 1] - starts = start_scores.argmax(axis=1) - ends = end_scores.argmax(axis=1) - - # TODO check start < end - - # get the old clusters (shape will be preserved) - clusters = doc2clusters(doc, self.input_prefix) - cidx = 0 - out_clusters = [] - for cluster in clusters: - ncluster = [] - for mention in cluster: - ncluster.append((starts[cidx], ends[cidx])) - cidx += 1 - out_clusters.append(ncluster) - else: - out_clusters = [] - out.append(out_clusters) - return out - - def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: - for doc, clusters in zip(docs, clusters_by_doc): - for ii, cluster in enumerate(clusters): - spans = [doc[mm[0]:mm[1]] for mm in cluster] - doc.spans[f"{self.output_prefix}_{ii}"] = spans - - def update( - self, - examples: Iterable[Example], - *, - drop: float = 0.0, - sgd: Optional[Optimizer] = None, - losses: Optional[Dict[str, float]] = None, - ) -> Dict[str, float]: - """Learn from a batch of documents and gold-standard information, - updating the pipe's model. Delegates to predict and get_loss. - """ - if losses is None: - losses = {} - losses.setdefault(self.name, 0.0) - validate_examples(examples, "SpanPredictor.update") - if not any(len(eg.reference) if eg.reference else 0 for eg in examples): - # Handle cases where there are no tokens in any docs. - return losses - set_dropout_rate(self.model, drop) - - total_loss = 0 - for eg in examples: - span_scores, backprop = self.model.begin_update([eg.predicted]) - # FIXME, this only happens once in the first 1000 docs of OntoNotes - # and I'm not sure yet why. - if span_scores.size: - loss, d_scores = self.get_loss([eg], span_scores) - total_loss += loss - # TODO check shape here - backprop((d_scores)) - - if sgd is not None: - self.finish_update(sgd) - losses[self.name] += total_loss - return losses - - def rehearse( - self, - examples: Iterable[Example], - *, - drop: float = 0.0, - sgd: Optional[Optimizer] = None, - losses: Optional[Dict[str, float]] = None, - ) -> Dict[str, float]: - # TODO this should be added later - raise NotImplementedError( - Errors.E931.format( - parent="SpanPredictor", method="add_label", name=self.name - ) - ) - - def add_label(self, label: str) -> int: - """Technically this method should be implemented from TrainablePipe, - but it is not relevant for this component. - """ - raise NotImplementedError( - Errors.E931.format( - parent="SpanPredictor", method="add_label", name=self.name - ) - ) - - def get_loss( - self, - examples: Iterable[Example], - span_scores: Floats3d, - ): - ops = self.model.ops - - # NOTE This is doing fake batching, and should always get a list of one example - assert len(examples) == 1, "Only fake batching is supported." - # starts and ends are gold starts and ends (Ints1d) - # span_scores is a Floats3d. What are the axes? mention x token x start/end - for eg in examples: - starts = [] - ends = [] - for key, sg in eg.reference.spans.items(): - if key.startswith(self.output_prefix): - for mention in sg: - starts.append(mention.start) - ends.append(mention.end) - - starts = self.model.ops.xp.asarray(starts) - ends = self.model.ops.xp.asarray(ends) - start_scores = span_scores[:, :, 0] - end_scores = span_scores[:, :, 1] - n_classes = start_scores.shape[1] - start_probs = ops.softmax(start_scores, axis=1) - end_probs = ops.softmax(end_scores, axis=1) - start_targets = to_categorical(starts, n_classes) - end_targets = to_categorical(ends, n_classes) - start_grads = (start_probs - start_targets) - end_grads = (end_probs - end_targets) - grads = ops.xp.stack((start_grads, end_grads), axis=2) - loss = float((grads ** 2).sum()) - return loss, grads - - def initialize( - self, - get_examples: Callable[[], Iterable[Example]], - *, - nlp: Optional[Language] = None, - ) -> None: - validate_get_examples(get_examples, "SpanPredictor.initialize") - - X = [] - Y = [] - for ex in islice(get_examples(), 2): - - if not ex.predicted.spans: - # set placeholder for shape inference - doc = ex.predicted - assert len(doc) > 2, "Coreference requires at least two tokens" - doc.spans[f"{self.input_prefix}_0"] = [doc[0:1], doc[1:2]] - X.append(ex.predicted) - Y.append(ex.reference) - - assert len(X) > 0, Errors.E923.format(name=self.name) - self.model.initialize(X=X, Y=Y) - - def score(self, examples, **kwargs): - """ - Evaluate on reconstructing the correct spans around - gold heads. - """ - scores = [] - xp = self.model.ops.xp - for eg in examples: - starts = [] - ends = [] - pred_starts = [] - pred_ends = [] - ref = eg.reference - pred = eg.predicted - for key, gold_sg in ref.spans.items(): - if key.startswith(self.output_prefix): - pred_sg = pred.spans[key] - for gold_mention, pred_mention in zip(gold_sg, pred_sg): - starts.append(gold_mention.start) - ends.append(gold_mention.end) - pred_starts.append(pred_mention.start) - pred_ends.append(pred_mention.end) - - starts = xp.asarray(starts) - ends = xp.asarray(ends) - pred_starts = xp.asarray(pred_starts) - pred_ends = xp.asarray(pred_ends) - correct = (starts == pred_starts) * (ends == pred_ends) - accuracy = correct.mean() - scores.append(float(accuracy)) - return {"span_accuracy": mean(scores)} diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py new file mode 100644 index 00000000000..951aae814c4 --- /dev/null +++ b/spacy/pipeline/span_predictor.py @@ -0,0 +1,280 @@ +from typing import Iterable, Tuple, Optional, Dict, Callable, Any, List +import warnings + +from thinc.types import Floats2d, Floats3d, Ints2d +from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy +from thinc.api import set_dropout_rate, to_categorical +from itertools import islice +from statistics import mean + +from .trainable_pipe import TrainablePipe +from ..language import Language +from ..training import Example, validate_examples, validate_get_examples +from ..errors import Errors +from ..scorer import Scorer +from ..tokens import Doc +from ..vocab import Vocab + +from ..ml.models.coref_util import ( + MentionClusters, + DEFAULT_CLUSTER_PREFIX, + doc2clusters, +) + +default_span_predictor_config = """ +[model] +@architectures = "spacy.SpanPredictor.v1" +hidden_size = 1024 +dist_emb_size = 64 + +[model.tok2vec] +@architectures = "spacy.Tok2Vec.v2" + +[model.tok2vec.embed] +@architectures = "spacy.MultiHashEmbed.v1" +width = 64 +rows = [2000, 2000, 1000, 1000, 1000, 1000] +attrs = ["ORTH", "LOWER", "PREFIX", "SUFFIX", "SHAPE", "ID"] +include_static_vectors = false + +[model.tok2vec.encode] +@architectures = "spacy.MaxoutWindowEncoder.v2" +width = ${model.tok2vec.embed.width} +window_size = 1 +maxout_pieces = 3 +depth = 2 +""" +DEFAULT_SPAN_PREDICTOR_MODEL = Config().from_str(default_span_predictor_config)["model"] + +@Language.factory( + "span_predictor", + assigns=["doc.spans"], + requires=["doc.spans"], + default_config={ + "model": DEFAULT_SPAN_PREDICTOR_MODEL, + "input_prefix": "coref_head_clusters", + "output_prefix": "coref_clusters", + }, + default_score_weights={"span_accuracy": 1.0}, + ) +def make_span_predictor( + nlp: Language, + name: str, + model, + input_prefix: str = "coref_head_clusters", + output_prefix: str = "coref_clusters", +) -> "SpanPredictor": + """Create a SpanPredictor component.""" + return SpanPredictor(nlp.vocab, model, name, input_prefix=input_prefix, output_prefix=output_prefix) + +class SpanPredictor(TrainablePipe): + """Pipeline component to resolve one-token spans to full spans. + + Used in coreference resolution. + """ + + def __init__( + self, + vocab: Vocab, + model: Model, + name: str = "span_predictor", + *, + input_prefix: str = "coref_head_clusters", + output_prefix: str = "coref_clusters", + ) -> None: + self.vocab = vocab + self.model = model + self.name = name + self.input_prefix = input_prefix + self.output_prefix = output_prefix + + self.cfg = {} + + def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: + # for now pretend there's just one doc + + out = [] + for doc in docs: + # TODO check shape here + span_scores = self.model.predict([doc]) + if span_scores.size: + # the information about clustering has to come from the input docs + # first let's convert the scores to a list of span idxs + start_scores = span_scores[:, :, 0] + end_scores = span_scores[:, :, 1] + starts = start_scores.argmax(axis=1) + ends = end_scores.argmax(axis=1) + + # TODO check start < end + + # get the old clusters (shape will be preserved) + clusters = doc2clusters(doc, self.input_prefix) + cidx = 0 + out_clusters = [] + for cluster in clusters: + ncluster = [] + for mention in cluster: + ncluster.append((starts[cidx], ends[cidx])) + cidx += 1 + out_clusters.append(ncluster) + else: + out_clusters = [] + out.append(out_clusters) + return out + + def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: + for doc, clusters in zip(docs, clusters_by_doc): + for ii, cluster in enumerate(clusters): + spans = [doc[mm[0]:mm[1]] for mm in cluster] + doc.spans[f"{self.output_prefix}_{ii}"] = spans + + def update( + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, + losses: Optional[Dict[str, float]] = None, + ) -> Dict[str, float]: + """Learn from a batch of documents and gold-standard information, + updating the pipe's model. Delegates to predict and get_loss. + """ + if losses is None: + losses = {} + losses.setdefault(self.name, 0.0) + validate_examples(examples, "SpanPredictor.update") + if not any(len(eg.reference) if eg.reference else 0 for eg in examples): + # Handle cases where there are no tokens in any docs. + return losses + set_dropout_rate(self.model, drop) + + total_loss = 0 + for eg in examples: + span_scores, backprop = self.model.begin_update([eg.predicted]) + # FIXME, this only happens once in the first 1000 docs of OntoNotes + # and I'm not sure yet why. + if span_scores.size: + loss, d_scores = self.get_loss([eg], span_scores) + total_loss += loss + # TODO check shape here + backprop((d_scores)) + + if sgd is not None: + self.finish_update(sgd) + losses[self.name] += total_loss + return losses + + def rehearse( + self, + examples: Iterable[Example], + *, + drop: float = 0.0, + sgd: Optional[Optimizer] = None, + losses: Optional[Dict[str, float]] = None, + ) -> Dict[str, float]: + # TODO this should be added later + raise NotImplementedError( + Errors.E931.format( + parent="SpanPredictor", method="add_label", name=self.name + ) + ) + + def add_label(self, label: str) -> int: + """Technically this method should be implemented from TrainablePipe, + but it is not relevant for this component. + """ + raise NotImplementedError( + Errors.E931.format( + parent="SpanPredictor", method="add_label", name=self.name + ) + ) + + def get_loss( + self, + examples: Iterable[Example], + span_scores: Floats3d, + ): + ops = self.model.ops + + # NOTE This is doing fake batching, and should always get a list of one example + assert len(examples) == 1, "Only fake batching is supported." + # starts and ends are gold starts and ends (Ints1d) + # span_scores is a Floats3d. What are the axes? mention x token x start/end + for eg in examples: + starts = [] + ends = [] + for key, sg in eg.reference.spans.items(): + if key.startswith(self.output_prefix): + for mention in sg: + starts.append(mention.start) + ends.append(mention.end) + + starts = self.model.ops.xp.asarray(starts) + ends = self.model.ops.xp.asarray(ends) + start_scores = span_scores[:, :, 0] + end_scores = span_scores[:, :, 1] + n_classes = start_scores.shape[1] + start_probs = ops.softmax(start_scores, axis=1) + end_probs = ops.softmax(end_scores, axis=1) + start_targets = to_categorical(starts, n_classes) + end_targets = to_categorical(ends, n_classes) + start_grads = (start_probs - start_targets) + end_grads = (end_probs - end_targets) + grads = ops.xp.stack((start_grads, end_grads), axis=2) + loss = float((grads ** 2).sum()) + return loss, grads + + def initialize( + self, + get_examples: Callable[[], Iterable[Example]], + *, + nlp: Optional[Language] = None, + ) -> None: + validate_get_examples(get_examples, "SpanPredictor.initialize") + + X = [] + Y = [] + for ex in islice(get_examples(), 2): + + if not ex.predicted.spans: + # set placeholder for shape inference + doc = ex.predicted + assert len(doc) > 2, "Coreference requires at least two tokens" + doc.spans[f"{self.input_prefix}_0"] = [doc[0:1], doc[1:2]] + X.append(ex.predicted) + Y.append(ex.reference) + + assert len(X) > 0, Errors.E923.format(name=self.name) + self.model.initialize(X=X, Y=Y) + + def score(self, examples, **kwargs): + """ + Evaluate on reconstructing the correct spans around + gold heads. + """ + scores = [] + xp = self.model.ops.xp + for eg in examples: + starts = [] + ends = [] + pred_starts = [] + pred_ends = [] + ref = eg.reference + pred = eg.predicted + for key, gold_sg in ref.spans.items(): + if key.startswith(self.output_prefix): + pred_sg = pred.spans[key] + for gold_mention, pred_mention in zip(gold_sg, pred_sg): + starts.append(gold_mention.start) + ends.append(gold_mention.end) + pred_starts.append(pred_mention.start) + pred_ends.append(pred_mention.end) + + starts = xp.asarray(starts) + ends = xp.asarray(ends) + pred_starts = xp.asarray(pred_starts) + pred_ends = xp.asarray(pred_ends) + correct = (starts == pred_starts) * (ends == pred_ends) + accuracy = correct.mean() + scores.append(float(accuracy)) + return {"span_accuracy": mean(scores)} From 41fc092674571c8da966dba0ad5cf19030fe7d26 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 10 May 2022 19:08:21 +0900 Subject: [PATCH 125/188] Split span predictor model into its own file --- spacy/ml/models/__init__.py | 1 + spacy/ml/models/coref.py | 200 --------------------------- spacy/ml/models/span_predictor.py | 215 ++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 200 deletions(-) create mode 100644 spacy/ml/models/span_predictor.py diff --git a/spacy/ml/models/__init__.py b/spacy/ml/models/__init__.py index 608f36393f0..9ae5b510463 100644 --- a/spacy/ml/models/__init__.py +++ b/spacy/ml/models/__init__.py @@ -1,4 +1,5 @@ from .coref import * #noqa +from .span_predictor import * #noqa from .entity_linker import * # noqa from .multi_task import * # noqa from .parser import * # noqa diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 835aeb1ce0c..4304e08c2f9 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -64,30 +64,6 @@ def build_wl_coref_model( return coref_model -@registry.architectures("spacy.SpanPredictor.v1") -def build_span_predictor( - tok2vec: Model[List[Doc], List[Floats2d]], - hidden_size: int = 1024, - dist_emb_size: int = 64, -): - # TODO fix this - try: - dim = tok2vec.get_dim("nO") - except ValueError: - # happens with transformer listener - dim = 768 - - with Model.define_operators({">>": chain, "&": tuplify}): - span_predictor = PyTorchWrapper( - SpanPredictor(dim, hidden_size, dist_emb_size), - convert_inputs=convert_span_predictor_inputs, - ) - # TODO use proper parameter for prefix - head_info = build_get_head_metadata("coref_head_clusters") - model = (tok2vec & head_info) >> span_predictor - - return model - def convert_coref_scorer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc @@ -120,61 +96,6 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: return (scores_xp, indices_xp), convert_for_torch_backward -def convert_span_predictor_inputs( - model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], is_train: bool -): - tok2vec, (sent_ids, head_ids) = X - # Normally we shoudl use the input is_train, but for these two it's not relevant - - def backprop(args: ArgsKwargs) -> List[Floats2d]: - # convert to xp and wrap in list - gradients = torch2xp(args.args[1]) - return [[gradients], None] - - word_features = xp2torch(tok2vec[0], requires_grad=is_train) - sent_ids = xp2torch(sent_ids[0], requires_grad=False) - if not head_ids[0].size: - head_ids = torch.empty(size=(0,)) - else: - head_ids = xp2torch(head_ids[0], requires_grad=False) - - argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) - # TODO actually support backprop - return argskwargs, backprop - - -# TODO This probably belongs in the component, not the model. -def predict_span_clusters( - span_predictor: Model, sent_ids: Ints1d, words: Floats2d, clusters: List[Ints1d] -): - """ - Predicts span clusters based on the word clusters. - - Args: - doc (Doc): the document data - words (torch.Tensor): [n_words, emb_size] matrix containing - embeddings for each of the words in the text - clusters (List[List[int]]): a list of clusters where each cluster - is a list of word indices - - Returns: - List[List[Span]]: span clusters - """ - if not clusters: - return [] - - xp = span_predictor.ops.xp - heads_ids = xp.asarray(sorted(i for cluster in clusters for i in cluster)) - scores = span_predictor.predict((sent_ids, words, heads_ids)) - starts = scores[:, :, 0].argmax(axis=1).tolist() - ends = (scores[:, :, 1].argmax(axis=1) + 1).tolist() - - head2span = { - head: (start, end) for head, start, end in zip(heads_ids.tolist(), starts, ends) - } - - return [[head2span[head] for head in cluster] for cluster in clusters] - # TODO add docstring for this, maybe move to utils. # This might belong in the component. @@ -205,36 +126,6 @@ def _clusterize(model, scores: Floats2d, top_indices: Ints2d): return sorted(clusters) -def build_get_head_metadata(prefix): - # TODO this name is awful, fix it - model = Model( - "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward - ) - return model - - -def head_data_forward(model, docs, is_train): - """A layer to generate the extra data needed for the span predictor.""" - sent_ids = [] - head_ids = [] - prefix = model.attrs["prefix"] - for doc in docs: - sids = model.ops.asarray2i(get_sentence_ids(doc)) - sent_ids.append(sids) - heads = [] - for key, sg in doc.spans.items(): - if not key.startswith(prefix): - continue - for span in sg: - # TODO warn if spans are more than one token - heads.append(span[0].i) - heads = model.ops.asarray2i(heads) - head_ids.append(heads) - # each of these is a list with one entry per doc - # backprop is just a placeholder - # TODO it would probably be better to have a list of tuples than two lists of arrays - return (sent_ids, head_ids), lambda x: [] - class CorefScorer(torch.nn.Module): """Combines all coref modules together to find coreferent spans. @@ -481,97 +372,6 @@ def _prune(self, rough_scores: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor return top_scores, indices -class SpanPredictor(torch.nn.Module): - def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): - super().__init__() - # input size = single token size - # 64 = probably distance emb size - # TODO check that dist_emb_size use is correct - self.ffnn = torch.nn.Sequential( - torch.nn.Linear(input_size * 2 + dist_emb_size, hidden_size), - torch.nn.ReLU(), - torch.nn.Dropout(0.3), - # TODO seems weird the 256 isn't a parameter??? - torch.nn.Linear(hidden_size, 256), - torch.nn.ReLU(), - torch.nn.Dropout(0.3), - # this use of dist_emb_size looks wrong but it was 64...? - torch.nn.Linear(256, dist_emb_size), - ) - self.conv = torch.nn.Sequential( - torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) - ) - self.emb = torch.nn.Embedding(128, dist_emb_size) # [-63, 63] + too_far - - def forward( - self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch - sent_id, - words: torch.Tensor, - heads_ids: torch.Tensor, - ) -> torch.Tensor: - """ - Calculates span start/end scores of words for each span head in - heads_ids - - Args: - doc (Doc): the document data - words (torch.Tensor): contextual embeddings for each word in the - document, [n_words, emb_size] - heads_ids (torch.Tensor): word indices of span heads - - Returns: - torch.Tensor: span start/end scores, [n_heads, n_words, 2] - """ - # If we don't receive heads, return empty - if heads_ids.nelement() == 0: - return torch.empty(size=(0,)) - # Obtain distance embedding indices, [n_heads, n_words] - relative_positions = heads_ids.unsqueeze(1) - torch.arange( - words.shape[0] - ).unsqueeze(0) - # make all valid distances positive - emb_ids = relative_positions + 63 - # "too_far" - emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 - # Obtain "same sentence" boolean mask, [n_heads, n_words] - heads_ids = heads_ids.long() - same_sent = sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0) - # To save memory, only pass candidates from one sentence for each head - # pair_matrix contains concatenated span_head_emb + candidate_emb + distance_emb - # for each candidate among the words in the same sentence as span_head - # [n_heads, input_size * 2 + distance_emb_size] - rows, cols = same_sent.nonzero(as_tuple=True) - pair_matrix = torch.cat( - ( - words[heads_ids[rows]], - words[cols], - self.emb(emb_ids[rows, cols]), - ), - dim=1, - ) - lengths = same_sent.sum(dim=1) - padding_mask = torch.arange(0, lengths.max().item()).unsqueeze(0) - padding_mask = padding_mask < lengths.unsqueeze(1) # [n_heads, max_sent_len] - # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] - # This is necessary to allow the convolution layer to look at several - # word scores - padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1]) - padded_pairs[padding_mask] = pair_matrix - - res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] - res = self.conv(res.permute(0, 2, 1)).permute( - 0, 2, 1 - ) # [n_heads, n_candidates, 2] - - scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float("-inf")) - scores[rows, cols] = res[padding_mask] - # Make sure that start <= head <= end during inference - if not self.training: - valid_starts = torch.log((relative_positions >= 0).to(torch.float)) - valid_ends = torch.log((relative_positions <= 0).to(torch.float)) - valid_positions = torch.stack((valid_starts, valid_ends), dim=2) - return scores + valid_positions - return scores class DistancePairwiseEncoder(torch.nn.Module): diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py new file mode 100644 index 00000000000..a4b54ec768f --- /dev/null +++ b/spacy/ml/models/span_predictor.py @@ -0,0 +1,215 @@ +from typing import List, Tuple +import torch + +from thinc.api import Model, chain, tuplify +from thinc.api import PyTorchWrapper, ArgsKwargs +from thinc.types import Floats2d, Ints1d, Ints2d +from thinc.util import xp2torch, torch2xp + +from ...tokens import Doc +from ...util import registry +from .coref_util import get_sentence_ids + +@registry.architectures("spacy.SpanPredictor.v1") +def build_span_predictor( + tok2vec: Model[List[Doc], List[Floats2d]], + hidden_size: int = 1024, + dist_emb_size: int = 64, +): + # TODO fix this + try: + dim = tok2vec.get_dim("nO") + except ValueError: + # happens with transformer listener + dim = 768 + + with Model.define_operators({">>": chain, "&": tuplify}): + span_predictor = PyTorchWrapper( + SpanPredictor(dim, hidden_size, dist_emb_size), + convert_inputs=convert_span_predictor_inputs, + ) + # TODO use proper parameter for prefix + head_info = build_get_head_metadata("coref_head_clusters") + model = (tok2vec & head_info) >> span_predictor + + return model + +def convert_span_predictor_inputs( + model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], is_train: bool +): + tok2vec, (sent_ids, head_ids) = X + # Normally we shoudl use the input is_train, but for these two it's not relevant + + def backprop(args: ArgsKwargs) -> List[Floats2d]: + # convert to xp and wrap in list + gradients = torch2xp(args.args[1]) + return [[gradients], None] + + word_features = xp2torch(tok2vec[0], requires_grad=is_train) + sent_ids = xp2torch(sent_ids[0], requires_grad=False) + if not head_ids[0].size: + head_ids = torch.empty(size=(0,)) + else: + head_ids = xp2torch(head_ids[0], requires_grad=False) + + argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) + # TODO actually support backprop + return argskwargs, backprop + + +# TODO This probably belongs in the component, not the model. +def predict_span_clusters( + span_predictor: Model, sent_ids: Ints1d, words: Floats2d, clusters: List[Ints1d] +): + """ + Predicts span clusters based on the word clusters. + + Args: + doc (Doc): the document data + words (torch.Tensor): [n_words, emb_size] matrix containing + embeddings for each of the words in the text + clusters (List[List[int]]): a list of clusters where each cluster + is a list of word indices + + Returns: + List[List[Span]]: span clusters + """ + if not clusters: + return [] + + xp = span_predictor.ops.xp + heads_ids = xp.asarray(sorted(i for cluster in clusters for i in cluster)) + scores = span_predictor.predict((sent_ids, words, heads_ids)) + starts = scores[:, :, 0].argmax(axis=1).tolist() + ends = (scores[:, :, 1].argmax(axis=1) + 1).tolist() + + head2span = { + head: (start, end) for head, start, end in zip(heads_ids.tolist(), starts, ends) + } + + return [[head2span[head] for head in cluster] for cluster in clusters] + +# TODO this should maybe have a different name from the component +class SpanPredictor(torch.nn.Module): + def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): + super().__init__() + # input size = single token size + # 64 = probably distance emb size + # TODO check that dist_emb_size use is correct + self.ffnn = torch.nn.Sequential( + torch.nn.Linear(input_size * 2 + dist_emb_size, hidden_size), + torch.nn.ReLU(), + torch.nn.Dropout(0.3), + # TODO seems weird the 256 isn't a parameter??? + torch.nn.Linear(hidden_size, 256), + torch.nn.ReLU(), + torch.nn.Dropout(0.3), + # this use of dist_emb_size looks wrong but it was 64...? + torch.nn.Linear(256, dist_emb_size), + ) + self.conv = torch.nn.Sequential( + torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) + ) + self.emb = torch.nn.Embedding(128, dist_emb_size) # [-63, 63] + too_far + + def forward( + self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + sent_id, + words: torch.Tensor, + heads_ids: torch.Tensor, + ) -> torch.Tensor: + """ + Calculates span start/end scores of words for each span head in + heads_ids + + Args: + doc (Doc): the document data + words (torch.Tensor): contextual embeddings for each word in the + document, [n_words, emb_size] + heads_ids (torch.Tensor): word indices of span heads + + Returns: + torch.Tensor: span start/end scores, [n_heads, n_words, 2] + """ + # If we don't receive heads, return empty + if heads_ids.nelement() == 0: + return torch.empty(size=(0,)) + # Obtain distance embedding indices, [n_heads, n_words] + relative_positions = heads_ids.unsqueeze(1) - torch.arange( + words.shape[0] + ).unsqueeze(0) + # make all valid distances positive + emb_ids = relative_positions + 63 + # "too_far" + emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 + # Obtain "same sentence" boolean mask, [n_heads, n_words] + heads_ids = heads_ids.long() + same_sent = sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0) + # To save memory, only pass candidates from one sentence for each head + # pair_matrix contains concatenated span_head_emb + candidate_emb + distance_emb + # for each candidate among the words in the same sentence as span_head + # [n_heads, input_size * 2 + distance_emb_size] + rows, cols = same_sent.nonzero(as_tuple=True) + pair_matrix = torch.cat( + ( + words[heads_ids[rows]], + words[cols], + self.emb(emb_ids[rows, cols]), + ), + dim=1, + ) + lengths = same_sent.sum(dim=1) + padding_mask = torch.arange(0, lengths.max().item()).unsqueeze(0) + padding_mask = padding_mask < lengths.unsqueeze(1) # [n_heads, max_sent_len] + # [n_heads, max_sent_len, input_size * 2 + distance_emb_size] + # This is necessary to allow the convolution layer to look at several + # word scores + padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1]) + padded_pairs[padding_mask] = pair_matrix + + res = self.ffnn(padded_pairs) # [n_heads, n_candidates, last_layer_output] + res = self.conv(res.permute(0, 2, 1)).permute( + 0, 2, 1 + ) # [n_heads, n_candidates, 2] + + scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float("-inf")) + scores[rows, cols] = res[padding_mask] + # Make sure that start <= head <= end during inference + if not self.training: + valid_starts = torch.log((relative_positions >= 0).to(torch.float)) + valid_ends = torch.log((relative_positions <= 0).to(torch.float)) + valid_positions = torch.stack((valid_starts, valid_ends), dim=2) + return scores + valid_positions + return scores + + +def build_get_head_metadata(prefix): + # TODO this name is awful, fix it + model = Model( + "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward + ) + return model + + +def head_data_forward(model, docs, is_train): + """A layer to generate the extra data needed for the span predictor.""" + sent_ids = [] + head_ids = [] + prefix = model.attrs["prefix"] + for doc in docs: + sids = model.ops.asarray2i(get_sentence_ids(doc)) + sent_ids.append(sids) + heads = [] + for key, sg in doc.spans.items(): + if not key.startswith(prefix): + continue + for span in sg: + # TODO warn if spans are more than one token + heads.append(span[0].i) + heads = model.ops.asarray2i(heads) + head_ids.append(heads) + # each of these is a list with one entry per doc + # backprop is just a placeholder + # TODO it would probably be better to have a list of tuples than two lists of arrays + return (sent_ids, head_ids), lambda x: [] + From 33f4f90ff0c90977ce26bacb437e85b7f8988269 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 10 May 2022 19:09:52 +0900 Subject: [PATCH 126/188] Formatting --- spacy/ml/models/coref.py | 5 --- spacy/ml/models/span_predictor.py | 66 ++++++++++++++++--------------- spacy/pipeline/coref.py | 5 +-- spacy/pipeline/span_predictor.py | 42 +++++++++++--------- 4 files changed, 58 insertions(+), 60 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 4304e08c2f9..4e8e604d815 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -64,7 +64,6 @@ def build_wl_coref_model( return coref_model - def convert_coref_scorer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc # just use the first @@ -96,7 +95,6 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: return (scores_xp, indices_xp), convert_for_torch_backward - # TODO add docstring for this, maybe move to utils. # This might belong in the component. def _clusterize(model, scores: Floats2d, top_indices: Ints2d): @@ -126,7 +124,6 @@ def _clusterize(model, scores: Floats2d, top_indices: Ints2d): return sorted(clusters) - class CorefScorer(torch.nn.Module): """Combines all coref modules together to find coreferent spans. @@ -372,8 +369,6 @@ def _prune(self, rough_scores: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor return top_scores, indices - - class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): super().__init__() diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index a4b54ec768f..779aa8c1efd 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -10,6 +10,7 @@ from ...util import registry from .coref_util import get_sentence_ids + @registry.architectures("spacy.SpanPredictor.v1") def build_span_predictor( tok2vec: Model[List[Doc], List[Floats2d]], @@ -34,6 +35,7 @@ def build_span_predictor( return model + def convert_span_predictor_inputs( model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], is_train: bool ): @@ -89,6 +91,38 @@ def predict_span_clusters( return [[head2span[head] for head in cluster] for cluster in clusters] + +def build_get_head_metadata(prefix): + # TODO this name is awful, fix it + model = Model( + "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward + ) + return model + + +def head_data_forward(model, docs, is_train): + """A layer to generate the extra data needed for the span predictor.""" + sent_ids = [] + head_ids = [] + prefix = model.attrs["prefix"] + for doc in docs: + sids = model.ops.asarray2i(get_sentence_ids(doc)) + sent_ids.append(sids) + heads = [] + for key, sg in doc.spans.items(): + if not key.startswith(prefix): + continue + for span in sg: + # TODO warn if spans are more than one token + heads.append(span[0].i) + heads = model.ops.asarray2i(heads) + head_ids.append(heads) + # each of these is a list with one entry per doc + # backprop is just a placeholder + # TODO it would probably be better to have a list of tuples than two lists of arrays + return (sent_ids, head_ids), lambda x: [] + + # TODO this should maybe have a different name from the component class SpanPredictor(torch.nn.Module): def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): @@ -181,35 +215,3 @@ def forward( valid_positions = torch.stack((valid_starts, valid_ends), dim=2) return scores + valid_positions return scores - - -def build_get_head_metadata(prefix): - # TODO this name is awful, fix it - model = Model( - "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward - ) - return model - - -def head_data_forward(model, docs, is_train): - """A layer to generate the extra data needed for the span predictor.""" - sent_ids = [] - head_ids = [] - prefix = model.attrs["prefix"] - for doc in docs: - sids = model.ops.asarray2i(get_sentence_ids(doc)) - sent_ids.append(sids) - heads = [] - for key, sg in doc.spans.items(): - if not key.startswith(prefix): - continue - for span in sg: - # TODO warn if spans are more than one token - heads.append(span[0].i) - heads = model.ops.asarray2i(heads) - head_ids.append(heads) - # each of these is a list with one entry per doc - # backprop is just a placeholder - # TODO it would probably be better to have a list of tuples than two lists of arrays - return (sent_ids, head_ids), lambda x: [] - diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 86a9d9e2cc2..dcc4434caaf 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -84,7 +84,6 @@ def make_coref( ) - class CoreferenceResolver(TrainablePipe): """Pipeline component for coreference resolution. @@ -318,7 +317,7 @@ def get_loss( log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) log_norm = ops.softmax(cscores, axis=1) grad = log_norm - log_marg - #gradients.append((grad, cidx)) + # gradients.append((grad, cidx)) loss = float((grad**2).sum()) return loss, grad @@ -373,5 +372,3 @@ def score(self, examples, **kwargs): "coref_r": evaluator.get_recall(), } return score - - diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index 951aae814c4..50c2e4ec6b0 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -46,26 +46,30 @@ """ DEFAULT_SPAN_PREDICTOR_MODEL = Config().from_str(default_span_predictor_config)["model"] + @Language.factory( - "span_predictor", - assigns=["doc.spans"], - requires=["doc.spans"], - default_config={ - "model": DEFAULT_SPAN_PREDICTOR_MODEL, - "input_prefix": "coref_head_clusters", - "output_prefix": "coref_clusters", - }, + "span_predictor", + assigns=["doc.spans"], + requires=["doc.spans"], + default_config={ + "model": DEFAULT_SPAN_PREDICTOR_MODEL, + "input_prefix": "coref_head_clusters", + "output_prefix": "coref_clusters", + }, default_score_weights={"span_accuracy": 1.0}, - ) +) def make_span_predictor( - nlp: Language, - name: str, - model, - input_prefix: str = "coref_head_clusters", - output_prefix: str = "coref_clusters", + nlp: Language, + name: str, + model, + input_prefix: str = "coref_head_clusters", + output_prefix: str = "coref_clusters", ) -> "SpanPredictor": """Create a SpanPredictor component.""" - return SpanPredictor(nlp.vocab, model, name, input_prefix=input_prefix, output_prefix=output_prefix) + return SpanPredictor( + nlp.vocab, model, name, input_prefix=input_prefix, output_prefix=output_prefix + ) + class SpanPredictor(TrainablePipe): """Pipeline component to resolve one-token spans to full spans. @@ -125,7 +129,7 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: for doc, clusters in zip(docs, clusters_by_doc): for ii, cluster in enumerate(clusters): - spans = [doc[mm[0]:mm[1]] for mm in cluster] + spans = [doc[mm[0] : mm[1]] for mm in cluster] doc.spans[f"{self.output_prefix}_{ii}"] = spans def update( @@ -218,10 +222,10 @@ def get_loss( end_probs = ops.softmax(end_scores, axis=1) start_targets = to_categorical(starts, n_classes) end_targets = to_categorical(ends, n_classes) - start_grads = (start_probs - start_targets) - end_grads = (end_probs - end_targets) + start_grads = start_probs - start_targets + end_grads = end_probs - end_targets grads = ops.xp.stack((start_grads, end_grads), axis=2) - loss = float((grads ** 2).sum()) + loss = float((grads**2).sum()) return loss, grads def initialize( From e512874c809bd35429979c66943af4212486a33e Mon Sep 17 00:00:00 2001 From: kadarakos Date: Tue, 10 May 2022 16:40:31 +0000 Subject: [PATCH 127/188] small refactor and docs --- spacy/ml/models/coref.py | 189 ++++++++++++++------------------------- 1 file changed, 67 insertions(+), 122 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 4e8e604d815..435c3bc80a6 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,14 +1,14 @@ from typing import List, Tuple import torch -from thinc.api import Model, chain, tuplify +from thinc.api import Model, chain from thinc.api import PyTorchWrapper, ArgsKwargs -from thinc.types import Floats2d, Ints1d, Ints2d +from thinc.types import Floats2d, Ints2d from thinc.util import xp2torch, torch2xp from ...tokens import Doc from ...util import registry -from .coref_util import add_dummy, get_sentence_ids +from .coref_util import add_dummy @registry.architectures("spacy.Coref.v1") @@ -19,7 +19,6 @@ def build_wl_coref_model( n_hidden_layers: int = 1, # TODO rename to "depth"? dropout: float = 0.3, # pairs to keep per mention after rough scoring - # TODO change to meaningful name rough_k: int = 50, # TODO is this not a training loop setting? a_scoring_batch_size: int = 512, @@ -34,7 +33,6 @@ def build_wl_coref_model( dim = 768 with Model.define_operators({">>": chain}): - # TODO chain tok2vec with these models coref_scorer = PyTorchWrapper( CorefScorer( dim, @@ -49,18 +47,6 @@ def build_wl_coref_model( convert_outputs=convert_coref_scorer_outputs, ) coref_model = tok2vec >> coref_scorer - # XXX just ignore this until the coref scorer is integrated - # span_predictor = PyTorchWrapper( - # SpanPredictor( - # TODO this was hardcoded to 1024, check - # hidden_size, - # sp_embedding_size, - # ), - # convert_inputs=convert_span_predictor_inputs - # ) - # TODO combine models so output is uniform (just one forward pass) - # It may be reasonable to have an option to disable span prediction, - # and just return words as spans. return coref_model @@ -95,46 +81,13 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: return (scores_xp, indices_xp), convert_for_torch_backward -# TODO add docstring for this, maybe move to utils. -# This might belong in the component. -def _clusterize(model, scores: Floats2d, top_indices: Ints2d): - xp = model.ops.xp - antecedents = scores.argmax(axis=1) - 1 - not_dummy = antecedents >= 0 - coref_span_heads = xp.arange(0, len(scores))[not_dummy] - antecedents = top_indices[coref_span_heads, antecedents[not_dummy]] - n_words = scores.shape[0] - nodes = [GraphNode(i) for i in range(n_words)] - for i, j in zip(coref_span_heads.tolist(), antecedents.tolist()): - nodes[i].link(nodes[j]) - assert nodes[i] is not nodes[j] - - clusters = [] - for node in nodes: - if len(node.links) > 0 and not node.visited: - cluster = [] - stack = [node] - while stack: - current_node = stack.pop() - current_node.visited = True - cluster.append(current_node.id) - stack.extend(link for link in current_node.links if not link.visited) - assert len(cluster) > 1 - clusters.append(sorted(cluster)) - return sorted(clusters) - - class CorefScorer(torch.nn.Module): - """Combines all coref modules together to find coreferent spans. - - Attributes: - epochs_trained (int): number of epochs the model has been trained for - + """ + Combines all coref modules together to find coreferent token pairs. Submodules (in the order of their usage in the pipeline): - rough_scorer (RoughScorer) - pw (PairwiseEncoder) - a_scorer (AnaphoricityScorer) - sp (SpanPredictor) + - rough_scorer (RoughScorer) that prunes candidate pairs + - pw (DistancePairwiseEncoder) that computes pairwise features + - a_scorer (AnaphoricityScorer) produces the final scores """ def __init__( @@ -149,50 +102,54 @@ def __init__( ): super().__init__() """ - A newly created model is set to evaluation mode. - - Args: - epochs_trained (int): the number of epochs finished - (useful for warm start) + dim: Size of the input features. + dist_emb_size: Size of the distance embeddings. + hidden_size: Size of the coreference candidate embeddings. + n_layers: Numbers of layers in the AnaphoricityScorer. + dropout_rate: Dropout probability to apply across all modules. + roughk: Number of candidates the RoughScorer returns. + batch_size: Internal batch-size for the more expensive AnaphoricityScorer. """ + self.dropout = torch.nn.Dropout(dropout_rate) + self.batch_size = batch_size + # Modules + self.lstm = torch.nn.LSTM( + input_size=dim, + hidden_size=dim, + batch_first=True, + ) + self.rough_scorer = RoughScorer(dim, dropout_rate, roughk) self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate) - # TODO clean this up - bert_emb = dim - pair_emb = bert_emb * 3 + self.pw.shape + pair_emb = dim * 3 + self.pw.shape self.a_scorer = AnaphoricityScorer( pair_emb, hidden_size, n_layers, dropout_rate ) - self.lstm = torch.nn.LSTM( - input_size=bert_emb, - hidden_size=bert_emb, - batch_first=True, - ) - self.dropout = torch.nn.Dropout(dropout_rate) - self.rough_scorer = RoughScorer(bert_emb, dropout_rate, roughk) - self.batch_size = batch_size def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ - This is a massive method, but it made sense to me to not split it into - several ones to let one see the data flow. + 1. LSTM encodes the incoming word_features. + 2. The RoughScorer scores and prunes the candidates. + 3. The DistancePairwiseEncoder embeds the distance between remaning pairs. + 4. The AnaphoricityScorer scores all pairs in mini-batches. - Args: - word_features: torch.Tensor containing word encodings - Returns: - coreference scores and top indices + word_features: torch.Tensor containing word encodings + + returns: + coref_scores: n_words x roughk floats. + top_indices: n_words x roughk integers. """ - # words [n_words, span_emb] - # cluster_ids [n_words] self.lstm.flatten_parameters() # XXX without this there's a warning word_features = torch.unsqueeze(word_features, dim=0) words, _ = self.lstm(word_features) words = words.squeeze() + # words: n_words x dim words = self.dropout(words) # Obtain bilinear scores and leave only top-k antecedents for each word - # top_rough_scores [n_words, n_ants] - # top_indices [n_words, n_ants] + # top_rough_scores: (n_words x roughk) + # top_indices: (n_words x roughk) top_rough_scores, top_indices = self.rough_scorer(words) - # Get pairwise features [n_words, n_ants, n_pw_features] + # Get pairwise features + # (n_words x roughk x n_pw_features) pw = self.pw(top_indices) batch_size = self.batch_size a_scores_lst: List[torch.Tensor] = [] @@ -272,13 +229,8 @@ def forward( def _ffnn(self, x: torch.Tensor) -> torch.Tensor: """ - Calculates anaphoricity scores. - - Args: - x: tensor of shape [batch_size, n_ants, n_features] - - Returns: - tensor of shape [batch_size, n_ants] + x: tensor of shape (batch_size x roughk x n_features + returns: tensor of shape (batch_size x rough_k) """ x = self.out(self.hidden(x)) return x.squeeze(2) @@ -293,21 +245,18 @@ def _get_pair_matrix( """ Builds the matrix used as input for AnaphoricityScorer. - Args: - all_mentions (torch.Tensor): [n_mentions, mention_emb], - all the valid mentions of the document, - can be on a different device - mentions_batch (torch.Tensor): [batch_size, mention_emb], - the mentions of the current batch, - is expected to be on the current device - pw_batch (torch.Tensor): [batch_size, n_ants, pw_emb], - pairwise features of the current batch, - is expected to be on the current device - top_indices_batch (torch.Tensor): [batch_size, n_ants], - indices of antecedents of each mention + all_mentions: (n_mentions x mention_emb), + all the valid mentions of the document, + can be on a different device + mentions_batch: (batch_size x mention_emb), + the mentions of the current batch. + pw_batch: (batch_size x roughk x pw_emb), + pairwise distance features of the current batch. + top_indices_batch: (batch_size x n_ants), + indices of antecedents of each mention Returns: - torch.Tensor: [batch_size, n_ants, pair_emb] + out: pairwise features (batch_size x n_ants x pair_emb) """ emb_size = mentions_batch.shape[1] n_ants = pw_batch.shape[1] @@ -322,16 +271,15 @@ def _get_pair_matrix( class RoughScorer(torch.nn.Module): """ - Is needed to give a roughly estimate of the anaphoricity of two candidates, - only top scoring candidates are considered on later steps to reduce - computational complexity. + Cheaper module that gives a rough estimate of the anaphoricity of two + candidates, only top scoring candidates are considered on later + steps to reduce computational cost. """ def __init__(self, features: int, dropout_rate: float, rough_k: float): super().__init__() self.dropout = torch.nn.Dropout(dropout_rate) self.bilinear = torch.nn.Linear(features, features) - self.k = rough_k def forward( @@ -348,21 +296,6 @@ def forward( pair_mask = torch.log((pair_mask > 0).to(torch.float)) bilinear_scores = self.dropout(self.bilinear(mentions)).mm(mentions.T) rough_scores = pair_mask + bilinear_scores - - return self._prune(rough_scores) - - def _prune(self, rough_scores: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - """ - Selects top-k rough antecedent scores for each mention. - - Args: - rough_scores: tensor of shape [n_mentions, n_mentions], containing - rough antecedent scores of each mention-antecedent pair. - - Returns: - FloatTensor of shape [n_mentions, k], top rough scores - LongTensor of shape [n_mentions, k], top indices - """ top_scores, indices = torch.topk( rough_scores, k=min(self.k, len(rough_scores)), dim=1, sorted=False ) @@ -371,6 +304,18 @@ def _prune(self, rough_scores: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor class DistancePairwiseEncoder(torch.nn.Module): def __init__(self, embedding_size, dropout_rate): + """ + Takes the top_indices indicating, which is a ranked + list for each word and its most likely corresponding + anaphora candidates. For each of these pairs it looks + up a distance embedding from a table, where the distance + corresponds to the log-distance. + + embedding_size: int, + Dimensionality of the distance-embeddings table. + dropout_rate: float, + Dropout probability. + """ super().__init__() emb_size = embedding_size self.distance_emb = torch.nn.Embedding(9, emb_size) @@ -378,7 +323,7 @@ def __init__(self, embedding_size, dropout_rate): self.shape = emb_size def forward( - self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + self, top_indices: torch.Tensor, ) -> torch.Tensor: word_ids = torch.arange(0, top_indices.size(0)) From b7ac4b33e2503d73dccd12984eb917953c9325a7 Mon Sep 17 00:00:00 2001 From: kadarakos Date: Wed, 11 May 2022 14:59:59 +0000 Subject: [PATCH 128/188] fixing arguments --- spacy/ml/models/coref.py | 8 ++++---- spacy/ml/models/span_predictor.py | 3 ++- spacy/pipeline/coref.py | 4 ++-- spacy/pipeline/span_predictor.py | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 24b5500a2d4..5042c10dac6 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -16,10 +16,10 @@ def build_wl_coref_model( tok2vec: Model[List[Doc], List[Floats2d]], embedding_size: int = 20, hidden_size: int = 1024, - n_hidden_layers: int = 1, # TODO rename to "depth"? + depth: int = 1, dropout: float = 0.3, # pairs to keep per mention after rough scoring - rough_k: int = 50, + rough_candidates: int = 50, # TODO is this not a training loop setting? a_scoring_batch_size: int = 512, # span predictor embeddings @@ -38,9 +38,9 @@ def build_wl_coref_model( dim, embedding_size, hidden_size, - n_hidden_layers, + depth, dropout, - rough_k, + rough_candidates, a_scoring_batch_size, ), convert_inputs=convert_coref_scorer_inputs, diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index b990b401944..ea445913bd8 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -16,6 +16,7 @@ def build_span_predictor( tok2vec: Model[List[Doc], List[Floats2d]], hidden_size: int = 1024, dist_emb_size: int = 64, + prefix: str = "coref_head_clusters" ): # TODO fix this try: @@ -30,7 +31,7 @@ def build_span_predictor( convert_inputs=convert_span_predictor_inputs, ) # TODO use proper parameter for prefix - head_info = build_get_head_metadata("coref_head_clusters") + head_info = build_get_head_metadata(prefix) model = (tok2vec & head_info) >> span_predictor return model diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 5237788cc9b..a8813b7a382 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -33,9 +33,9 @@ @architectures = "spacy.Coref.v1" embedding_size = 20 hidden_size = 1024 -n_hidden_layers = 1 +depth = 1 dropout = 0.3 -rough_k = 50 +rough_candidates = 50 a_scoring_batch_size = 512 sp_embedding_size = 64 diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index 50c2e4ec6b0..d0561054da2 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -26,6 +26,7 @@ @architectures = "spacy.SpanPredictor.v1" hidden_size = 1024 dist_emb_size = 64 +prefix = coref_head_clusters [model.tok2vec] @architectures = "spacy.Tok2Vec.v2" From 14eb20f07a0c57992ee9bcd755e985ddd25b8c4e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 12 May 2022 13:47:06 +0900 Subject: [PATCH 129/188] Add span predictor docs --- website/docs/api/coref.md | 8 +- website/docs/api/span-predictor.md | 340 +++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 website/docs/api/span-predictor.md diff --git a/website/docs/api/coref.md b/website/docs/api/coref.md index 53ed6a4c8ed..4d43645f328 100644 --- a/website/docs/api/coref.md +++ b/website/docs/api/coref.md @@ -92,9 +92,9 @@ shortcut for this and instantiate the component using its string name and Apply the pipe to one document. The document is modified in place and returned. This usually happens under the hood when the `nlp` object is called on a text and all pipeline components are applied to the `Doc` in order. Both -[`__call__`](/api/entitylinker#call) and [`pipe`](/api/entitylinker#pipe) -delegate to the [`predict`](/api/entitylinker#predict) and -[`set_annotations`](/api/entitylinker#set_annotations) methods. +[`__call__`](/api/coref#call) and [`pipe`](/api/coref#pipe) delegate to the +[`predict`](/api/coref#predict) and +[`set_annotations`](/api/coref#set_annotations) methods. > #### Example > @@ -197,7 +197,7 @@ Modify a batch of documents, saving coreference clusters in `Doc.spans`. ## CoreferenceResolver.update {#update tag="method"} Learn from a batch of [`Example`](/api/example) objects. Delegates to -[`predict`](/api/entitylinker#predict). +[`predict`](/api/coref#predict). > #### Example > diff --git a/website/docs/api/span-predictor.md b/website/docs/api/span-predictor.md new file mode 100644 index 00000000000..1e99b49b2b2 --- /dev/null +++ b/website/docs/api/span-predictor.md @@ -0,0 +1,340 @@ +--- +title: SpanPredictor +tag: class +source: spacy/pipeline/span_predictor.py +new: 3.4 +teaser: 'Pipeline component for resolving tokens into spans' +api_base_class: /api/pipe +api_string_name: span_predictor +api_trainable: true +--- + +A `SpanPredictor` component takes in tokens (represented as `Span`s of length + +1. and resolves them into `Span`s of arbitrary length. The initial use case is + as a post-processing step on word-level [coreference resolution](/api/coref). + The input and output keys used to store `Span`s are configurable. + +## Assigned Attributes {#assigned-attributes} + +Predictions will be saved to `Doc.spans` as [`SpanGroup`s](/api/spangroup). + +Input token spans will be read in using an input prefix, by default +`"coref_head_clusters"`, and output spans will be saved using an output prefix +(default `"coref_clusters"`) plus a serial number starting from zero. The +prefixes are configurable. + +| Location | Value | +| ------------------------------------------------- | ------------------------------------------- | +| `Doc.spans[output_prefix + "_" + cluster_number]` | One group of predicted spans. ~~SpanGroup~~ | + +## Config and implementation {#config} + +The default config is defined by the pipeline component factory and describes +how the component should be configured. You can override its settings via the +`config` argument on [`nlp.add_pipe`](/api/language#add_pipe) or in your +[`config.cfg` for training](/usage/training#config). See the +[model architectures](/api/architectures) documentation for details on the +architectures and their arguments and hyperparameters. + +> #### Example +> +> ```python +> from spacy.pipeline.span_predictor import DEFAULT_SPAN_PREDICTOR_MODEL +> config={ +> "model": DEFAULT_SPAN_PREDICTOR_MODEL, +> "span_cluster_prefix": DEFAULT_CLUSTER_PREFIX, +> }, +> nlp.add_pipe("span_predictor", config=config) +> ``` + +| Setting | Description | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `model` | The [`Model`](https://thinc.ai/docs/api-model) powering the pipeline component. Defaults to [SpanPredictor](/api/architectures#SpanPredictor). ~~Model~~ | +| `input_prefix` | The prefix to use for input `SpanGroup`s. Defaults to `coref_head_clusters`. ~~str~~ | +| `output_prefix` | The prefix for predicted `SpanGroup`s. Defaults to `coref_clusters`. ~~str~~ | + +```python +%%GITHUB_SPACY/spacy/pipeline/span_predictor.py +``` + +## SpanPredictor.\_\_init\_\_ {#init tag="method"} + +> #### Example +> +> ```python +> # Construction via add_pipe with default model +> span_predictor = nlp.add_pipe("span_predictor") +> +> # Construction via add_pipe with custom model +> config = {"model": {"@architectures": "my_span_predictor.v1"}} +> span_predictor = nlp.add_pipe("span_predictor", config=config) +> +> # Construction from class +> from spacy.pipeline import SpanPredictor +> span_predictor = SpanPredictor(nlp.vocab, model) +> ``` + +Create a new pipeline instance. In your application, you would normally use a +shortcut for this and instantiate the component using its string name and +[`nlp.add_pipe`](/api/language#add_pipe). + +| Name | Description | +| --------------- | --------------------------------------------------------------------------------------------------- | +| `vocab` | The shared vocabulary. ~~Vocab~~ | +| `model` | The [`Model`](https://thinc.ai/docs/api-model) powering the pipeline component. ~~Model~~ | +| `name` | String name of the component instance. Used to add entries to the `losses` during training. ~~str~~ | +| _keyword-only_ | | +| `input_prefix` | The prefix to use for input `SpanGroup`s. Defaults to `coref_head_clusters`. ~~str~~ | +| `output_prefix` | The prefix for predicted `SpanGroup`s. Defaults to `coref_clusters`. ~~str~~ | + +## SpanPredictor.\_\_call\_\_ {#call tag="method"} + +Apply the pipe to one document. The document is modified in place and returned. +This usually happens under the hood when the `nlp` object is called on a text +and all pipeline components are applied to the `Doc` in order. Both +[`__call__`](#call) and [`pipe`](#pipe) delegate to the [`predict`](#predict) +and [`set_annotations`](#set_annotations) methods. + +> #### Example +> +> ```python +> doc = nlp("This is a sentence.") +> span_predictor = nlp.add_pipe("span_predictor") +> # This usually happens under the hood +> processed = span_predictor(doc) +> ``` + +| Name | Description | +| ----------- | -------------------------------- | +| `doc` | The document to process. ~~Doc~~ | +| **RETURNS** | The processed document. ~~Doc~~ | + +## SpanPredictor.pipe {#pipe tag="method"} + +Apply the pipe to a stream of documents. This usually happens under the hood +when the `nlp` object is called on a text and all pipeline components are +applied to the `Doc` in order. Both [`__call__`](/api/span-predictor#call) and +[`pipe`](/api/span-predictor#pipe) delegate to the +[`predict`](/api/span-predictor#predict) and +[`set_annotations`](/api/span-predictor#set_annotations) methods. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> for doc in span_predictor.pipe(docs, batch_size=50): +> pass +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------- | +| `stream` | A stream of documents. ~~Iterable[Doc]~~ | +| _keyword-only_ | | +| `batch_size` | The number of documents to buffer. Defaults to `128`. ~~int~~ | +| **YIELDS** | The processed documents in order. ~~Doc~~ | + +## SpanPredictor.initialize {#initialize tag="method"} + +Initialize the component for training. `get_examples` should be a function that +returns an iterable of [`Example`](/api/example) objects. The data examples are +used to **initialize the model** of the component and can either be the full +training data or a representative sample. Initialization includes validating the +network, +[inferring missing shapes](https://thinc.ai/docs/usage-models#validation) and +setting up the label scheme based on the data. This method is typically called +by [`Language.initialize`](/api/language#initialize). + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> span_predictor.initialize(lambda: [], nlp=nlp) +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `get_examples` | Function that returns gold-standard annotations in the form of [`Example`](/api/example) objects. ~~Callable[[], Iterable[Example]]~~ | +| _keyword-only_ | | +| `nlp` | The current `nlp` object. Defaults to `None`. ~~Optional[Language]~~ | + +## SpanPredictor.predict {#predict tag="method"} + +Apply the component's model to a batch of [`Doc`](/api/doc) objects, without +modifying them. Predictions are returned as a list of `MentionClusters`, one for +each input `Doc`. A `MentionClusters` instance is just a list of lists of pairs +of `int`s, where each item corresponds to an input `SpanGroup`, and the `int`s +correspond to token indices. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> spans = span_predictor.predict([doc1, doc2]) +> ``` + +| Name | Description | +| ----------- | ------------------------------------------------------------- | +| `docs` | The documents to predict. ~~Iterable[Doc]~~ | +| **RETURNS** | The predicted spans for the `Doc`s. ~~List[MentionClusters]~~ | + +## SpanPredictor.set_annotations {#set_annotations tag="method"} + +Modify a batch of documents, saving predictions using the output prefix in +`Doc.spans`. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> spans = span_predictor.predict([doc1, doc2]) +> span_predictor.set_annotations([doc1, doc2], spans) +> ``` + +| Name | Description | +| ------- | ------------------------------------------------------------- | +| `docs` | The documents to modify. ~~Iterable[Doc]~~ | +| `spans` | The predicted spans for the `docs`. ~~List[MentionClusters]~~ | + +## SpanPredictor.update {#update tag="method"} + +Learn from a batch of [`Example`](/api/example) objects. Delegates to +[`predict`](/api/span-predictor#predict). + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> optimizer = nlp.initialize() +> losses = span_predictor.update(examples, sgd=optimizer) +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `examples` | A batch of [`Example`](/api/example) objects to learn from. ~~Iterable[Example]~~ | +| _keyword-only_ | | +| `drop` | The dropout rate. ~~float~~ | +| `sgd` | An optimizer. Will be created via [`create_optimizer`](#create_optimizer) if not set. ~~Optional[Optimizer]~~ | +| `losses` | Optional record of the loss during training. Updated using the component name as the key. ~~Optional[Dict[str, float]]~~ | +| **RETURNS** | The updated `losses` dictionary. ~~Dict[str, float]~~ | + +## SpanPredictor.create_optimizer {#create_optimizer tag="method"} + +Create an optimizer for the pipeline component. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> optimizer = span_predictor.create_optimizer() +> ``` + +| Name | Description | +| ----------- | ---------------------------- | +| **RETURNS** | The optimizer. ~~Optimizer~~ | + +## SpanPredictor.use_params {#use_params tag="method, contextmanager"} + +Modify the pipe's model, to use the given parameter values. At the end of the +context, the original parameters are restored. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> with span_predictor.use_params(optimizer.averages): +> span_predictor.to_disk("/best_model") +> ``` + +| Name | Description | +| -------- | -------------------------------------------------- | +| `params` | The parameter values to use in the model. ~~dict~~ | + +## SpanPredictor.to_disk {#to_disk tag="method"} + +Serialize the pipe to disk. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> span_predictor.to_disk("/path/to/span_predictor") +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `path` | A path to a directory, which will be created if it doesn't exist. Paths may be either strings or `Path`-like objects. ~~Union[str, Path]~~ | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | + +## SpanPredictor.from_disk {#from_disk tag="method"} + +Load the pipe from disk. Modifies the object in place and returns it. + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> span_predictor.from_disk("/path/to/span_predictor") +> ``` + +| Name | Description | +| -------------- | ----------------------------------------------------------------------------------------------- | +| `path` | A path to a directory. Paths may be either strings or `Path`-like objects. ~~Union[str, Path]~~ | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | +| **RETURNS** | The modified `SpanPredictor` object. ~~SpanPredictor~~ | + +## SpanPredictor.to_bytes {#to_bytes tag="method"} + +> #### Example +> +> ```python +> span_predictor = nlp.add_pipe("span_predictor") +> span_predictor_bytes = span_predictor.to_bytes() +> ``` + +Serialize the pipe to a bytestring. + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------- | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | +| **RETURNS** | The serialized form of the `SpanPredictor` object. ~~bytes~~ | + +## SpanPredictor.from_bytes {#from_bytes tag="method"} + +Load the pipe from a bytestring. Modifies the object in place and returns it. + +> #### Example +> +> ```python +> span_predictor_bytes = span_predictor.to_bytes() +> span_predictor = nlp.add_pipe("span_predictor") +> span_predictor.from_bytes(span_predictor_bytes) +> ``` + +| Name | Description | +| -------------- | ------------------------------------------------------------------------------------------- | +| `bytes_data` | The data to load from. ~~bytes~~ | +| _keyword-only_ | | +| `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | +| **RETURNS** | The `SpanPredictor` object. ~~SpanPredictor~~ | + +## Serialization fields {#serialization-fields} + +During serialization, spaCy will export several data fields used to restore +different aspects of the object. If needed, you can exclude them from +serialization by passing in the string names via the `exclude` argument. + +> #### Example +> +> ```python +> data = span_predictor.to_disk("/path", exclude=["vocab"]) +> ``` + +| Name | Description | +| ------- | -------------------------------------------------------------- | +| `vocab` | The shared [`Vocab`](/api/vocab). | +| `cfg` | The config file. You usually don't want to exclude this. | +| `model` | The binary model data. You usually don't want to exclude this. | From 6a8625e7116a58c284a390580058a732f7d6c5f0 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 13 May 2022 19:28:55 +0900 Subject: [PATCH 130/188] First draft for architecture docs These parameters are probably going to be renamed / have defaults adjusted. Also Model types are off. --- website/docs/api/architectures.md | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/website/docs/api/architectures.md b/website/docs/api/architectures.md index 2bddcb28cc9..fab07af6538 100644 --- a/website/docs/api/architectures.md +++ b/website/docs/api/architectures.md @@ -922,3 +922,66 @@ A function that takes as input a [`KnowledgeBase`](/api/kb) and a plausible [`Candidate`](/api/kb/#candidate) objects. The default `CandidateGenerator` simply uses the text of a mention to find its potential aliases in the `KnowledgeBase`. Note that this function is case-dependent. + +## Coreference Architectures + +A [`CoreferenceResolver`](/api/coref) component identifies tokens that refer to +the same entity. A [`SpanPredictor`](/api/span-predictor) component infers spans +from single tokens. Together these components can be used to reproduce +traditional coreference models. You can also omit the `SpanPredictor` for faster +performance if working with only token-level clusters is acceptable. + +### spacy.Coref.v1 {#Coref} + +> #### Example Config +> +> ```ini +> +> [model] +> @architectures = "spacy.Coref.v1" +> embedding_size = 20 +> dropout = 0.3 +> hidden_size = 1024 +> n_hidden_layers = 2 +> rough_k = 50 +> a_scoring_batch_size = 512 +> +> [model.tok2vec] +> @architectures = "spacy-transformers.TransformerListener.v1" +> grad_factor = 1.0 +> upstream = "transformer" +> pooling = {"@layers":"reduce_mean.v1"} +> ``` + +The `Coref` model architecture is a Thinc `Model`. + +| Name | Description | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tok2vec` | The [`tok2vec`](#tok2vec) layer of the model. ~~Model~~ | +| `embedding_size` | ~~int~~ | +| `dropout` | The dropout to use internally. Unlike some Thinc models, this has separate dropout for the internal PyTorch layers. ~~float~~ | +| `hidden_size` | Size of the main internal layers. ~~int~~ | +| `n_hidden_layers` | Depth of the internal network. ~~int~~ | +| `rough_k` | How many candidate antecedents to keep after rough scoring. This has a significant effect on memory usage. Typical values would be 50 to 200, or higher for very long documents. ~~int~~ | +| `a_scoring_batch_size` | Internal batch size. ~~int~~ | +| **CREATES** | The model using the architecture. ~~Model[List[Doc], Floats2d]~~ | + +### spacy.SpanPredictor.v1 {#SpanPredictor} + +> #### Example Config +> +> ```ini +> +> [model] +> @architectures = "spacy.SpanPredictor.v1" +> hidden_size = 1024 +> dist_emb_size = 64 +> +> [model.tok2vec] +> @architectures = "spacy-transformers.TransformerListener.v1" +> grad_factor = 1.0 +> upstream = "transformer" +> pooling = {"@layers":"reduce_mean.v1"} +> ``` + +The `SpanPredictor` model architecture is a Thinc `Model`. From 13481fbcc2b4e35cf26de356e5cd3c6b49a2c93f Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 13 May 2022 19:29:28 +0900 Subject: [PATCH 131/188] Remove unused param, add TODOs about typing --- spacy/ml/models/coref.py | 3 +-- spacy/ml/models/span_predictor.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 24b5500a2d4..cfbe83a7a9f 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -22,9 +22,8 @@ def build_wl_coref_model( rough_k: int = 50, # TODO is this not a training loop setting? a_scoring_batch_size: int = 512, - # span predictor embeddings - sp_embedding_size: int = 64, ): + # TODO add model return types # TODO fix this try: dim = tok2vec.get_dim("nO") diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index b990b401944..c5cbb328ce7 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -17,6 +17,7 @@ def build_span_predictor( hidden_size: int = 1024, dist_emb_size: int = 64, ): + # TODO add model return types # TODO fix this try: dim = tok2vec.get_dim("nO") From 2e8f0e9168fe8a05b3f40ac84995273d31691d37 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 16 May 2022 16:50:10 +0900 Subject: [PATCH 132/188] Rename coref params --- spacy/ml/models/coref.py | 59 +++++++++++++++---------------- spacy/pipeline/coref.py | 9 +++-- website/docs/api/architectures.md | 39 ++++++++++++-------- 3 files changed, 58 insertions(+), 49 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index cfbe83a7a9f..299abdc6ba1 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -14,14 +14,13 @@ @registry.architectures("spacy.Coref.v1") def build_wl_coref_model( tok2vec: Model[List[Doc], List[Floats2d]], - embedding_size: int = 20, + distance_embedding_size: int = 20, hidden_size: int = 1024, - n_hidden_layers: int = 1, # TODO rename to "depth"? + depth: int = 1, dropout: float = 0.3, # pairs to keep per mention after rough scoring - rough_k: int = 50, - # TODO is this not a training loop setting? - a_scoring_batch_size: int = 512, + antecedent_limit: int = 50, + antecedent_batch_size: int = 512, ): # TODO add model return types # TODO fix this @@ -35,12 +34,12 @@ def build_wl_coref_model( coref_scorer = PyTorchWrapper( CorefScorer( dim, - embedding_size, + distance_embedding_size, hidden_size, - n_hidden_layers, + depth, dropout, - rough_k, - a_scoring_batch_size, + antecedent_limit, + antecedent_batch_size, ), convert_inputs=convert_coref_scorer_inputs, convert_outputs=convert_coref_scorer_outputs, @@ -99,7 +98,7 @@ def __init__( dist_emb_size: int, hidden_size: int, n_layers: int, - dropout_rate: float, + dropout: float, roughk: int, batch_size: int, ): @@ -109,31 +108,31 @@ def __init__( dist_emb_size: Size of the distance embeddings. hidden_size: Size of the coreference candidate embeddings. n_layers: Numbers of layers in the AnaphoricityScorer. - dropout_rate: Dropout probability to apply across all modules. + dropout: Dropout probability to apply across all modules. roughk: Number of candidates the RoughScorer returns. batch_size: Internal batch-size for the more expensive scorer. """ - self.dropout = torch.nn.Dropout(dropout_rate) + self.dropout = torch.nn.Dropout(dropout) self.batch_size = batch_size # Modules - self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate) + self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) pair_emb = dim * 3 + self.pw.shape self.a_scorer = AnaphoricityScorer( pair_emb, hidden_size, n_layers, - dropout_rate + dropout ) self.lstm = torch.nn.LSTM( input_size=dim, hidden_size=dim, batch_first=True, ) - self.rough_scorer = RoughScorer(dim, dropout_rate, roughk) - self.pw = DistancePairwiseEncoder(dist_emb_size, dropout_rate) + self.rough_scorer = RoughScorer(dim, dropout, roughk) + self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) pair_emb = dim * 3 + self.pw.shape self.a_scorer = AnaphoricityScorer( - pair_emb, hidden_size, n_layers, dropout_rate + pair_emb, hidden_size, n_layers, dropout ) def forward( @@ -190,18 +189,18 @@ def forward( class AnaphoricityScorer(torch.nn.Module): """Calculates anaphoricity scores by passing the inputs into a FFNN""" - def __init__(self, in_features: int, hidden_size, n_hidden_layers, dropout_rate): + def __init__(self, in_features: int, hidden_size, depth, dropout): super().__init__() hidden_size = hidden_size - if not n_hidden_layers: + if not depth: hidden_size = in_features layers = [] - for i in range(n_hidden_layers): + for i in range(depth): layers.extend( [ torch.nn.Linear(hidden_size if i else in_features, hidden_size), torch.nn.LeakyReLU(), - torch.nn.Dropout(dropout_rate), + torch.nn.Dropout(dropout), ] ) self.hidden = torch.nn.Sequential(*layers) @@ -243,7 +242,7 @@ def forward( def _ffnn(self, x: torch.Tensor) -> torch.Tensor: """ x: tensor of shape (batch_size x roughk x n_features - returns: tensor of shape (batch_size x rough_k) + returns: tensor of shape (batch_size x antecedent_limit) """ x = self.out(self.hidden(x)) return x.squeeze(2) @@ -289,11 +288,11 @@ class RoughScorer(torch.nn.Module): steps to reduce computational cost. """ - def __init__(self, features: int, dropout_rate: float, rough_k: float): + def __init__(self, features: int, dropout: float, antecedent_limit: int): super().__init__() - self.dropout = torch.nn.Dropout(dropout_rate) + self.dropout = torch.nn.Dropout(dropout) self.bilinear = torch.nn.Linear(features, features) - self.k = rough_k + self.k = antecedent_limit def forward( self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch @@ -317,7 +316,7 @@ def forward( class DistancePairwiseEncoder(torch.nn.Module): - def __init__(self, embedding_size, dropout_rate): + def __init__(self, distance_embedding_size, dropout): """ Takes the top_indices indicating, which is a ranked list for each word and its most likely corresponding @@ -325,15 +324,15 @@ def __init__(self, embedding_size, dropout_rate): up a distance embedding from a table, where the distance corresponds to the log-distance. - embedding_size: int, + distance_embedding_size: int, Dimensionality of the distance-embeddings table. - dropout_rate: float, + dropout: float, Dropout probability. """ super().__init__() - emb_size = embedding_size + emb_size = distance_embedding_size self.distance_emb = torch.nn.Embedding(9, emb_size) - self.dropout = torch.nn.Dropout(dropout_rate) + self.dropout = torch.nn.Dropout(dropout) self.shape = emb_size def forward( diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 5237788cc9b..c5bf8fbbef0 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -31,13 +31,12 @@ default_config = """ [model] @architectures = "spacy.Coref.v1" -embedding_size = 20 +distance_embedding_size = 20 hidden_size = 1024 -n_hidden_layers = 1 +depth = 1 dropout = 0.3 -rough_k = 50 -a_scoring_batch_size = 512 -sp_embedding_size = 64 +antecedent_limit = 50 +antecedent_batch_size = 512 [model.tok2vec] @architectures = "spacy.Tok2Vec.v2" diff --git a/website/docs/api/architectures.md b/website/docs/api/architectures.md index fab07af6538..1a807928d10 100644 --- a/website/docs/api/architectures.md +++ b/website/docs/api/architectures.md @@ -939,12 +939,12 @@ performance if working with only token-level clusters is acceptable. > > [model] > @architectures = "spacy.Coref.v1" -> embedding_size = 20 +> distance_embedding_size = 20 > dropout = 0.3 > hidden_size = 1024 -> n_hidden_layers = 2 -> rough_k = 50 -> a_scoring_batch_size = 512 +> depth = 2 +> antecedent_limit = 50 +> antecedent_batch_size = 512 > > [model.tok2vec] > @architectures = "spacy-transformers.TransformerListener.v1" @@ -955,16 +955,16 @@ performance if working with only token-level clusters is acceptable. The `Coref` model architecture is a Thinc `Model`. -| Name | Description | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tok2vec` | The [`tok2vec`](#tok2vec) layer of the model. ~~Model~~ | -| `embedding_size` | ~~int~~ | -| `dropout` | The dropout to use internally. Unlike some Thinc models, this has separate dropout for the internal PyTorch layers. ~~float~~ | -| `hidden_size` | Size of the main internal layers. ~~int~~ | -| `n_hidden_layers` | Depth of the internal network. ~~int~~ | -| `rough_k` | How many candidate antecedents to keep after rough scoring. This has a significant effect on memory usage. Typical values would be 50 to 200, or higher for very long documents. ~~int~~ | -| `a_scoring_batch_size` | Internal batch size. ~~int~~ | -| **CREATES** | The model using the architecture. ~~Model[List[Doc], Floats2d]~~ | +| Name | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tok2vec` | The [`tok2vec`](#tok2vec) layer of the model. ~~Model~~ | +| `distance_embedding_size` | A representation of the distance between candidates. ~~int~~ | +| `dropout` | The dropout to use internally. Unlike some Thinc models, this has separate dropout for the internal PyTorch layers. ~~float~~ | +| `hidden_size` | Size of the main internal layers. ~~int~~ | +| `depth` | Depth of the internal network. ~~int~~ | +| `antecedent_limit` | How many candidate antecedents to keep after rough scoring. This has a significant effect on memory usage. Typical values would be 50 to 200, or higher for very long documents. ~~int~~ | +| `antecedent_batch_size` | Internal batch size. ~~int~~ | +| **CREATES** | The model using the architecture. ~~Model[List[Doc], Floats2d]~~ | ### spacy.SpanPredictor.v1 {#SpanPredictor} @@ -985,3 +985,14 @@ The `Coref` model architecture is a Thinc `Model`. > ``` The `SpanPredictor` model architecture is a Thinc `Model`. + +| Name | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tok2vec` | The [`tok2vec`](#tok2vec) layer of the model. ~~Model~~ | +| `distance_embedding_size` | A representation of the distance between two candidates. ~~int~~ | +| `dropout` | The dropout to use internally. Unlike some Thinc models, this has separate dropout for the internal PyTorch layers. ~~float~~ | +| `hidden_size` | Size of the main internal layers. ~~int~~ | +| `depth` | Depth of the internal network. ~~int~~ | +| `antecedent_limit` | How many candidate antecedents to keep after rough scoring. This has a significant effect on memory usage. Typical values would be 50 to 200, or higher for very long documents. ~~int~~ | +| `antecedent_batch_size` | Internal batch size. ~~int~~ | +| **CREATES** | The model using the architecture. ~~Model[List[Doc], TupleFloats2d]~~ | From 1dc38944472d3506f0a9427f8d71163eb7550da1 Mon Sep 17 00:00:00 2001 From: kadarakos Date: Tue, 17 May 2022 15:36:32 +0000 Subject: [PATCH 133/188] new parameters --- spacy/ml/models/span_predictor.py | 43 +++++++++++++++++++++++++------ spacy/pipeline/span_predictor.py | 5 +++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index da9c78c9811..7375c215382 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -15,7 +15,10 @@ def build_span_predictor( tok2vec: Model[List[Doc], List[Floats2d]], hidden_size: int = 1024, - dist_emb_size: int = 64, + distance_embedding_size: int = 64, + conv_channels: int = 4, + window_size: int = 1, + max_distance: int = 128, prefix: str = "coref_head_clusters" ): # TODO add model return types @@ -28,7 +31,14 @@ def build_span_predictor( with Model.define_operators({">>": chain, "&": tuplify}): span_predictor = PyTorchWrapper( - SpanPredictor(dim, hidden_size, dist_emb_size), + SpanPredictor( + dim, + hidden_size, + distance_embedding_size, + conv_channels, + window_size, + max_distance + ), convert_inputs=convert_span_predictor_inputs, ) # TODO use proper parameter for prefix @@ -123,8 +133,21 @@ def head_data_forward(model, docs, is_train): # TODO this should maybe have a different name from the component class SpanPredictor(torch.nn.Module): - def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): + def __init__( + self, + input_size: int, + hidden_size: int, + dist_emb_size: int, + conv_channels: int, + window_size: int, + max_distance: int + + ): super().__init__() + if max_distance % 2 != 0: + raise ValueError( + "max_distance has to be an even number" + ) # input size = single token size # 64 = probably distance emb size # TODO check that dist_emb_size use is correct @@ -139,12 +162,15 @@ def __init__(self, input_size: int, hidden_size: int, dist_emb_size: int): # this use of dist_emb_size looks wrong but it was 64...? torch.nn.Linear(256, dist_emb_size), ) - # TODO make the Convs also parametrizeable + kernel_size = window_size * 2 + 1 self.conv = torch.nn.Sequential( - torch.nn.Conv1d(64, 4, 3, 1, 1), torch.nn.Conv1d(4, 2, 3, 1, 1) + torch.nn.Conv1d(dist_emb_size, conv_channels, kernel_size, 1, 1), + torch.nn.Conv1d(conv_channels, 2, kernel_size, 1, 1) ) # TODO make embeddings size a parameter - self.emb = torch.nn.Embedding(128, dist_emb_size) # [-63, 63] + too_far + self.max_distance = max_distance + # handle distances between +-(max_distance - 2 / 2) + self.emb = torch.nn.Embedding(max_distance, dist_emb_size) def forward( self, @@ -170,10 +196,11 @@ def forward( relative_positions = heads_ids.unsqueeze(1) - torch.arange( words.shape[0] ).unsqueeze(0) + md = self.max_distance # make all valid distances positive - emb_ids = relative_positions + 63 + emb_ids = relative_positions + (md - 2) // 2 # "too_far" - emb_ids[(emb_ids < 0) + (emb_ids > 126)] = 127 + emb_ids[(emb_ids < 0) + (emb_ids > md - 2)] = md - 1 # Obtain "same sentence" boolean mask: (n_heads x n_words) heads_ids = heads_ids.long() same_sent = sent_id[heads_ids].unsqueeze(1) == sent_id.unsqueeze(0) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index d0561054da2..12ea6611cc2 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -25,7 +25,10 @@ [model] @architectures = "spacy.SpanPredictor.v1" hidden_size = 1024 -dist_emb_size = 64 +distance_embedding_size = 64 +conv_channels = 4 +window_size = 1 +max_distance = 128 prefix = coref_head_clusters [model.tok2vec] From 9da16df96edb5ecb2dbe261b002a750d02efadf4 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 24 May 2022 15:16:25 +0900 Subject: [PATCH 134/188] Add guards around torch import Torch is required for the coref/spanpred models but shouldn't be required for spaCy in general. The one tricky part of this is that one function in coref_util relied on torch, but that file was imported in several places. Since the function was only used in one place I moved it there. --- spacy/ml/models/__init__.py | 11 +++++++++-- spacy/ml/models/coref.py | 17 ++++++++++++++++- spacy/ml/models/coref_util.py | 15 --------------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/spacy/ml/models/__init__.py b/spacy/ml/models/__init__.py index 9ae5b510463..b0172196404 100644 --- a/spacy/ml/models/__init__.py +++ b/spacy/ml/models/__init__.py @@ -1,5 +1,3 @@ -from .coref import * #noqa -from .span_predictor import * #noqa from .entity_linker import * # noqa from .multi_task import * # noqa from .parser import * # noqa @@ -7,3 +5,12 @@ from .tagger import * # noqa from .textcat import * # noqa from .tok2vec import * # noqa + +# some models require Torch +try: + import torch + from .coref import * #noqa + from .span_predictor import * #noqa +except ImportError: + pass + diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 299abdc6ba1..0667053c661 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -8,7 +8,6 @@ from ...tokens import Doc from ...util import registry -from .coref_util import add_dummy @registry.architectures("spacy.Coref.v1") @@ -186,6 +185,22 @@ def forward( return coref_scores, top_indices +# Note this function is kept here to keep a torch dep out of coref_util. +def add_dummy(tensor: torch.Tensor, eps: bool = False): + """Prepends zeros (or a very small value if eps is True) + to the first (not zeroth) dimension of tensor. + """ + kwargs = dict(device=tensor.device, dtype=tensor.dtype) + shape: List[int] = list(tensor.shape) + shape[1] = 1 + if not eps: + dummy = torch.zeros(shape, **kwargs) # type: ignore + else: + dummy = torch.full(shape, EPSILON, **kwargs) # type: ignore + output = torch.cat((dummy, tensor), dim=1) + return output + + class AnaphoricityScorer(torch.nn.Module): """Calculates anaphoricity scores by passing the inputs into a FFNN""" diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index e8de1e0acbf..05f83189a3f 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -2,7 +2,6 @@ from spacy.tokens import Doc from typing import List, Tuple, Callable, Any, Set, Dict from ...util import registry -import torch # type alias to make writing this less tedious MentionClusters = List[List[Tuple[int, int]]] @@ -25,20 +24,6 @@ def __repr__(self) -> str: return str(self.id) -def add_dummy(tensor: torch.Tensor, eps: bool = False): - """ Prepends zeros (or a very small value if eps is True) - to the first (not zeroth) dimension of tensor. - """ - kwargs = dict(device=tensor.device, dtype=tensor.dtype) - shape: List[int] = list(tensor.shape) - shape[1] = 1 - if not eps: - dummy = torch.zeros(shape, **kwargs) # type: ignore - else: - dummy = torch.full(shape, EPSILON, **kwargs) # type: ignore - output = torch.cat((dummy, tensor), dim=1) - return output - def get_sentence_ids(doc): out = [] sent_id = -1 From b1118cee584d8a1a4eb40dfd6d9660388807cf12 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 24 May 2022 15:59:08 +0900 Subject: [PATCH 135/188] Move epsilon --- spacy/ml/models/coref.py | 1 + spacy/ml/models/coref_util.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 0667053c661..b4d8030e83e 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -185,6 +185,7 @@ def forward( return coref_scores, top_indices +EPSILON = 1e-7 # Note this function is kept here to keep a torch dep out of coref_util. def add_dummy(tensor: torch.Tensor, eps: bool = False): """Prepends zeros (or a very small value if eps is True) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 05f83189a3f..86dd0df4b2f 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -8,8 +8,6 @@ DEFAULT_CLUSTER_PREFIX = "coref_clusters" -EPSILON = 1e-7 - class GraphNode: def __init__(self, node_id: int): self.id = node_id From 5cbc9f4573686857cd5b2cfb8cc38fb51ff2a5a2 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 24 May 2022 16:02:39 +0900 Subject: [PATCH 136/188] Use thinc.util.has_torch --- spacy/ml/models/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spacy/ml/models/__init__.py b/spacy/ml/models/__init__.py index b0172196404..4368a556d5c 100644 --- a/spacy/ml/models/__init__.py +++ b/spacy/ml/models/__init__.py @@ -7,10 +7,8 @@ from .tok2vec import * # noqa # some models require Torch -try: - import torch +from thinc.util import has_torch +if has_torch: from .coref import * #noqa from .span_predictor import * #noqa -except ImportError: - pass From c9233a5a1f34b2e8397d40abfde283412bc79b6e Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 24 May 2022 17:28:27 +0900 Subject: [PATCH 137/188] Import torch from thinc --- spacy/ml/models/coref.py | 3 +-- spacy/ml/models/span_predictor.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index b4d8030e83e..ca90115772f 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,10 +1,9 @@ from typing import List, Tuple -import torch from thinc.api import Model, chain from thinc.api import PyTorchWrapper, ArgsKwargs from thinc.types import Floats2d, Ints2d, Ints1d -from thinc.util import xp2torch, torch2xp +from thinc.util import torch, xp2torch, torch2xp from ...tokens import Doc from ...util import registry diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 7375c215382..1ded9c3c7d5 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -1,10 +1,9 @@ from typing import List, Tuple -import torch from thinc.api import Model, chain, tuplify from thinc.api import PyTorchWrapper, ArgsKwargs from thinc.types import Floats2d, Ints1d -from thinc.util import xp2torch, torch2xp +from thinc.util import torch, xp2torch, torch2xp from ...tokens import Doc from ...util import registry From 303269c4b2487d586bc5aa231164b1f88d246fa4 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 25 May 2022 18:26:31 +0900 Subject: [PATCH 138/188] Skip coref test if no torch --- spacy/tests/test_models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spacy/tests/test_models.py b/spacy/tests/test_models.py index ce074fe4213..794f9ca8797 100644 --- a/spacy/tests/test_models.py +++ b/spacy/tests/test_models.py @@ -2,11 +2,13 @@ import pytest from thinc.api import fix_random_seed, Adam, set_dropout_rate from thinc.api import Ragged, reduce_mean, Logistic, chain, Relu +from thinc.util import has_torch from numpy.testing import assert_array_equal, assert_array_almost_equal import numpy from spacy.ml.models import build_Tok2Vec_model, MultiHashEmbed, MaxoutWindowEncoder from spacy.ml.models import build_bow_text_classifier, build_simple_cnn_text_classifier -from spacy.ml.models import build_spancat_model, build_wl_coref_model +if has_torch: + from spacy.ml.models import build_spancat_model, build_wl_coref_model from spacy.ml.staticvectors import StaticVectors from spacy.ml.extract_spans import extract_spans, _get_span_indices from spacy.lang.en import English @@ -271,6 +273,7 @@ def test_spancat_model_forward_backward(nO=5): backprop(Y) #TODO expand this +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_coref_model_init(): tok2vec = build_Tok2Vec_model(**get_tok2vec_kwargs()) model = build_wl_coref_model(tok2vec) From 69994362707648360e70d3c4fb32757024063c23 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 25 May 2022 18:32:47 +0900 Subject: [PATCH 139/188] Fix coref tests --- spacy/tests/pipeline/test_coref.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 61ef6de6f4c..25de6e35634 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -8,8 +8,7 @@ from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX from spacy.ml.models.coref_util import ( select_non_crossing_spans, - get_candidate_mentions, - get_sentence_map, + get_sentence_ids, ) # fmt: off @@ -159,22 +158,7 @@ def test_crossing_spans(): guess = sorted(guess) assert gold == guess - -def test_mention_generator(snlp): - nlp = snlp - doc = nlp("I like text.") # four tokens - max_width = 20 - mentions = get_candidate_mentions(doc, max_width) - assert len(mentions[0]) == 10 - - # check multiple sentences - doc = nlp("I like text. This is text.") # eight tokens, two sents - max_width = 20 - mentions = get_candidate_mentions(doc, max_width) - assert len(mentions[0]) == 20 - - def test_sentence_map(snlp): doc = snlp("I like text. This is text.") - sm = get_sentence_map(doc) + sm = get_sentence_ids(doc) assert sm == [0, 0, 0, 0, 1, 1, 1, 1] From 6087da9675439753c91b78301c46d1fa4453ed5f Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 25 May 2022 19:11:48 +0900 Subject: [PATCH 140/188] Suggestions from code review, cleanup, typing --- spacy/pipeline/coref.py | 61 +++++++---------------------------------- 1 file changed, 10 insertions(+), 51 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index c5bf8fbbef0..76e790896dd 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -2,7 +2,7 @@ import warnings from thinc.types import Floats2d, Floats3d, Ints2d -from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy +from thinc.api import Model, Config, Optimizer from thinc.api import set_dropout_rate, to_categorical from itertools import islice from statistics import mean @@ -11,7 +11,6 @@ from ..language import Language from ..training import Example, validate_examples, validate_get_examples from ..errors import Errors -from ..scorer import Scorer from ..tokens import Doc from ..vocab import Vocab @@ -118,7 +117,7 @@ def __init__( self.span_cluster_prefix = span_cluster_prefix self._rehearsal_model = None - self.cfg = {} + self.cfg: Dict[str, Any] = {} def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: """Apply the pipeline's model to a batch of docs, without modifying them. @@ -154,6 +153,7 @@ def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: DOCS: https://spacy.io/api/coref#set_annotations (TODO) """ + docs = list(docs) if len(docs) != len(clusters_by_doc): raise ValueError( "Found coref clusters incompatible with the " @@ -219,49 +219,8 @@ def update( losses[self.name] += total_loss return losses - def rehearse( - self, - examples: Iterable[Example], - *, - drop: float = 0.0, - sgd: Optional[Optimizer] = None, - losses: Optional[Dict[str, float]] = None, - ) -> Dict[str, float]: - """Perform a "rehearsal" update from a batch of data. Rehearsal updates - teach the current model to make predictions similar to an initial model, - to try to address the "catastrophic forgetting" problem. This feature is - experimental. - - examples (Iterable[Example]): A batch of Example objects. - drop (float): The dropout rate. - sgd (thinc.api.Optimizer): The optimizer. - losses (Dict[str, float]): Optional record of the loss during training. - Updated using the component name as the key. - RETURNS (Dict[str, float]): The updated losses dictionary. - - DOCS: https://spacy.io/api/coref#rehearse (TODO) - """ - if losses is not None: - losses.setdefault(self.name, 0.0) - if self._rehearsal_model is None: - return losses - validate_examples(examples, "CoreferenceResolver.rehearse") - # TODO test this whole function - docs = [eg.predicted for eg in examples] - if not any(len(doc) for doc in docs): - # Handle cases where there are no tokens in any docs. - return losses - set_dropout_rate(self.model, drop) - scores, bp_scores = self.model.begin_update(docs) - # TODO below - target = self._rehearsal_model(examples) - gradient = scores - target - bp_scores(gradient) - if sgd is not None: - self.finish_update(sgd) - if losses is not None: - losses[self.name] += (gradient**2).sum() - return losses + def rehearse(self, examples, *, sgd=None, losses=None, **config): + raise NotImplementedError def add_label(self, label: str) -> int: """Technically this method should be implemented from TrainablePipe, @@ -276,7 +235,7 @@ def add_label(self, label: str) -> int: def get_loss( self, examples: Iterable[Example], - score_matrix: List[Tuple[Floats2d, Ints2d]], + score_matrix: Floats2d, mention_idx: Ints2d, ): """Find the loss and gradient of loss for the batch of documents and @@ -293,13 +252,13 @@ def get_loss( # TODO if there is more than one example, give an error # (or actually rework this to take multiple things) - example = examples[0] - cscores = score_matrix + example = list(examples)[0] cidx = mention_idx clusters = get_clusters_from_doc(example.reference) span_idxs = create_head_span_idxs(ops, len(example.predicted)) gscores = create_gold_scores(span_idxs, clusters) + # TODO fix type here. This is bools but asarray2f wants ints. gscores = ops.asarray2f(gscores) # top_gscores = xp.take_along_axis(gscores, cidx, axis=1) top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) @@ -313,8 +272,8 @@ def get_loss( with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=RuntimeWarning) - log_marg = ops.softmax(cscores + ops.xp.log(top_gscores), axis=1) - log_norm = ops.softmax(cscores, axis=1) + log_marg = ops.softmax(score_matrix + ops.xp.log(top_gscores), axis=1) + log_norm = ops.softmax(score_matrix, axis=1) grad = log_norm - log_marg # gradients.append((grad, cidx)) loss = float((grad**2).sum()) From e721c7bed8f9f4c847484e83b5a59bc5a1799a40 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 25 May 2022 19:12:20 +0900 Subject: [PATCH 141/188] Import cleanup --- spacy/ml/models/coref_util.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 86dd0df4b2f..3e28ca8eef3 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -1,7 +1,6 @@ from thinc.types import Ints2d from spacy.tokens import Doc -from typing import List, Tuple, Callable, Any, Set, Dict -from ...util import registry +from typing import List, Tuple, Set # type alias to make writing this less tedious MentionClusters = List[List[Tuple[int, int]]] From 2a8efda68997320cf31b180cbf60cd504b451a1c Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 25 May 2022 19:18:26 +0900 Subject: [PATCH 142/188] Code review suggestions, cleanup --- spacy/coref_scorer.py | 4 ++-- spacy/ml/models/coref.py | 16 ++++++++-------- spacy/ml/models/span_predictor.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/spacy/coref_scorer.py b/spacy/coref_scorer.py index b266ec3b32e..981b1cf03f8 100644 --- a/spacy/coref_scorer.py +++ b/spacy/coref_scorer.py @@ -9,14 +9,14 @@ def get_cluster_info(predicted_clusters, gold_clusters): return (gold_clusters, predicted_clusters, g2p, p2g) -def get_markable_assignments(inp_clusters, out_clusters): +def get_markable_assignments(in_clusters, out_clusters): markable_cluster_ids = {} out_dic = {} for cluster_id, cluster in enumerate(out_clusters): for m in cluster: out_dic[m] = cluster_id - for cluster in inp_clusters: + for cluster in in_clusters: for im in cluster: for om in out_dic: if im == om: diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index ca90115772f..4be22dd963a 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -29,8 +29,8 @@ def build_wl_coref_model( dim = 768 with Model.define_operators({">>": chain}): - coref_scorer = PyTorchWrapper( - CorefScorer( + coref_clusterer = PyTorchWrapper( + CorefClusterer( dim, distance_embedding_size, hidden_size, @@ -39,14 +39,14 @@ def build_wl_coref_model( antecedent_limit, antecedent_batch_size, ), - convert_inputs=convert_coref_scorer_inputs, - convert_outputs=convert_coref_scorer_outputs, + convert_inputs=convert_coref_clusterer_inputs, + convert_outputs=convert_coref_clusterer_outputs, ) - coref_model = tok2vec >> coref_scorer + coref_model = tok2vec >> coref_clusterer return coref_model -def convert_coref_scorer_inputs( +def convert_coref_clusterer_inputs( model: Model, X: List[Floats2d], is_train: bool @@ -65,7 +65,7 @@ def backprop(args: ArgsKwargs) -> List[Floats2d]: return ArgsKwargs(args=(word_features, ), kwargs={}), backprop -def convert_coref_scorer_outputs(model: Model, inputs_outputs, is_train: bool): +def convert_coref_clusterer_outputs(model: Model, inputs_outputs, is_train: bool): _, outputs = inputs_outputs scores, indices = outputs @@ -81,7 +81,7 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: return (scores_xp, indices_xp), convert_for_torch_backward -class CorefScorer(torch.nn.Module): +class CorefClusterer(torch.nn.Module): """ Combines all coref modules together to find coreferent token pairs. Submodules (in the order of their usage in the pipeline): diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 1ded9c3c7d5..03101edf99a 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -48,7 +48,7 @@ def build_span_predictor( def convert_span_predictor_inputs( - model: Model, X: Tuple[Ints1d, Floats2d, Ints1d], is_train: bool + model: Model, X: Tuple[Ints1d, Tuple[Floats2d, Ints1d]], is_train: bool ): tok2vec, (sent_ids, head_ids) = X # Normally we should use the input is_train, but for these two it's not relevant From 838f50192bae4f556a200ca66805741ec31d2bb5 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 25 May 2022 19:20:03 +0900 Subject: [PATCH 143/188] Black formatting --- spacy/ml/models/coref.py | 31 +++++++------------------------ spacy/ml/models/coref_util.py | 9 ++++++--- spacy/ml/models/span_predictor.py | 25 +++++++++++-------------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 4be22dd963a..d5961949888 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -46,11 +46,7 @@ def build_wl_coref_model( return coref_model -def convert_coref_clusterer_inputs( - model: Model, - X: List[Floats2d], - is_train: bool -): +def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc # just use the first # TODO real batching @@ -62,7 +58,7 @@ def backprop(args: ArgsKwargs) -> List[Floats2d]: gradients = torch2xp(args.args[0]) return [gradients] - return ArgsKwargs(args=(word_features, ), kwargs={}), backprop + return ArgsKwargs(args=(word_features,), kwargs={}), backprop def convert_coref_clusterer_outputs(model: Model, inputs_outputs, is_train: bool): @@ -115,12 +111,7 @@ def __init__( # Modules self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) pair_emb = dim * 3 + self.pw.shape - self.a_scorer = AnaphoricityScorer( - pair_emb, - hidden_size, - n_layers, - dropout - ) + self.a_scorer = AnaphoricityScorer(pair_emb, hidden_size, n_layers, dropout) self.lstm = torch.nn.LSTM( input_size=dim, hidden_size=dim, @@ -129,13 +120,9 @@ def __init__( self.rough_scorer = RoughScorer(dim, dropout, roughk) self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) pair_emb = dim * 3 + self.pw.shape - self.a_scorer = AnaphoricityScorer( - pair_emb, hidden_size, n_layers, dropout - ) + self.a_scorer = AnaphoricityScorer(pair_emb, hidden_size, n_layers, dropout) - def forward( - self, word_features: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ 1. LSTM encodes the incoming word_features. 2. The RoughScorer scores and prunes the candidates. @@ -350,13 +337,9 @@ def __init__(self, distance_embedding_size, dropout): self.dropout = torch.nn.Dropout(dropout) self.shape = emb_size - def forward( - self, - top_indices: torch.Tensor - ) -> torch.Tensor: + def forward(self, top_indices: torch.Tensor) -> torch.Tensor: word_ids = torch.arange(0, top_indices.size(0)) - distance = (word_ids.unsqueeze(1) - word_ids[top_indices] - ).clamp_min_(min=1) + distance = (word_ids.unsqueeze(1) - word_ids[top_indices]).clamp_min_(min=1) log_distance = distance.to(torch.float).log2().floor_() log_distance = log_distance.clamp_max_(max=6).to(torch.long) distance = torch.where(distance < 5, distance - 1, log_distance + 2) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 3e28ca8eef3..8d0ff7bb02c 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -7,6 +7,7 @@ DEFAULT_CLUSTER_PREFIX = "coref_clusters" + class GraphNode: def __init__(self, node_id: int): self.id = node_id @@ -30,6 +31,7 @@ def get_sentence_ids(doc): out.append(sent_id) return out + def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: """Given a doc, give the mention clusters. @@ -100,7 +102,6 @@ def get_predicted_clusters( return predicted_clusters - def select_non_crossing_spans( idxs: List[int], starts: List[int], ends: List[int], limit: int ) -> List[int]: @@ -150,12 +151,14 @@ def select_non_crossing_spans( # selected.append(selected[0]) # this seems a bit weird? return selected + def create_head_span_idxs(ops, doclen: int): """Helper function to create single-token span indices.""" aa = ops.xp.arange(0, doclen) bb = ops.xp.arange(0, doclen) + 1 return ops.asarray2i([aa, bb]).T + def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: """Given a Doc, convert the cluster spans to simple int tuple lists.""" out = [] @@ -163,10 +166,10 @@ def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: cluster = [] for span in val: # TODO check that there isn't an off-by-one error here - #cluster.append((span.start, span.end)) + # cluster.append((span.start, span.end)) # TODO This conversion should be happening earlier in processing head_i = span.root.i - cluster.append( (head_i, head_i + 1) ) + cluster.append((head_i, head_i + 1)) # don't want duplicates cluster = list(set(cluster)) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 03101edf99a..a8c4d1aaa7e 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -18,7 +18,7 @@ def build_span_predictor( conv_channels: int = 4, window_size: int = 1, max_distance: int = 128, - prefix: str = "coref_head_clusters" + prefix: str = "coref_head_clusters", ): # TODO add model return types # TODO fix this @@ -36,7 +36,7 @@ def build_span_predictor( distance_embedding_size, conv_channels, window_size, - max_distance + max_distance, ), convert_inputs=convert_span_predictor_inputs, ) @@ -133,20 +133,17 @@ def head_data_forward(model, docs, is_train): # TODO this should maybe have a different name from the component class SpanPredictor(torch.nn.Module): def __init__( - self, - input_size: int, - hidden_size: int, - dist_emb_size: int, - conv_channels: int, - window_size: int, - max_distance: int - + self, + input_size: int, + hidden_size: int, + dist_emb_size: int, + conv_channels: int, + window_size: int, + max_distance: int, ): super().__init__() if max_distance % 2 != 0: - raise ValueError( - "max_distance has to be an even number" - ) + raise ValueError("max_distance has to be an even number") # input size = single token size # 64 = probably distance emb size # TODO check that dist_emb_size use is correct @@ -164,7 +161,7 @@ def __init__( kernel_size = window_size * 2 + 1 self.conv = torch.nn.Sequential( torch.nn.Conv1d(dist_emb_size, conv_channels, kernel_size, 1, 1), - torch.nn.Conv1d(conv_channels, 2, kernel_size, 1, 1) + torch.nn.Conv1d(conv_channels, 2, kernel_size, 1, 1), ) # TODO make embeddings size a parameter self.max_distance = max_distance From f75a5287875473f265036501f1639536521f1d65 Mon Sep 17 00:00:00 2001 From: Adriane Boyd Date: Wed, 25 May 2022 13:05:41 +0200 Subject: [PATCH 144/188] Update spacy/ml/models/spancat.py --- spacy/ml/models/spancat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/spancat.py b/spacy/ml/models/spancat.py index 29926c4fdad..893db2e6d76 100644 --- a/spacy/ml/models/spancat.py +++ b/spacy/ml/models/spancat.py @@ -30,7 +30,7 @@ def build_mean_max_reducer(hidden_size: int) -> Model[Ragged, Floats2d]: reduce_max(), ), Maxout(nO=hidden_size, normalize=True, dropout=0.0), - ) + ) @registry.architectures("spacy.SpanCategorizer.v1") From b8bdf998ade11ba14c7bebcfe764322c6e654622 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 25 May 2022 13:12:37 +0200 Subject: [PATCH 145/188] fix types in scorer + black --- spacy/scorer.py | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/spacy/scorer.py b/spacy/scorer.py index 4856bfc0dc2..8ee6294ad62 100644 --- a/spacy/scorer.py +++ b/spacy/scorer.py @@ -477,29 +477,57 @@ def score_clusters( score_per_type[label] = PRFScore() # Find all instances, for all and per type gold_instances = set() - gold_per_type = {label: set() for label in labels} + gold_per_type: Dict[str, Set] = {label: set() for label in labels} for gold_cluster in gold_clusters: for span1 in gold_cluster: for span2 in gold_cluster: # only record pairs where span1 comes before span2 - if (span1.start < span2.start) or (span1.start == span2.start and span1.end < span2.end): + if (span1.start < span2.start) or ( + span1.start == span2.start and span1.end < span2.end + ): if include_label: - gold_rel = (span1.label_, span1.start, span1.end - 1, span2.label_, span2.start, span2.end - 1) + gold_rel: Tuple = ( + span1.label_, + span1.start, + span1.end - 1, + span2.label_, + span2.start, + span2.end - 1, + ) else: - gold_rel = (span1.start, span1.end - 1, span2.start, span2.end - 1) + gold_rel = ( + span1.start, + span1.end - 1, + span2.start, + span2.end - 1, + ) gold_instances.add(gold_rel) if span1.label_ == span2.label_: gold_per_type[span1.label_].add(gold_rel) pred_instances = set() - pred_per_type = {label: set() for label in labels} + pred_per_type: Dict[str, Set] = {label: set() for label in labels} for pred_cluster in pred_clusters: for span1 in pred_cluster: for span2 in pred_cluster: - if (span1.start < span2.start) or (span1.start == span2.start and span1.end < span2.end): + if (span1.start < span2.start) or ( + span1.start == span2.start and span1.end < span2.end + ): if include_label: - pred_rel = (span1.label_, span1.start, span1.end - 1, span2.label_, span2.start, span2.end - 1) + pred_rel: Tuple = ( + span1.label_, + span1.start, + span1.end - 1, + span2.label_, + span2.start, + span2.end - 1, + ) else: - pred_rel = (span1.start, span1.end - 1, span2.start, span2.end - 1) + pred_rel = ( + span1.start, + span1.end - 1, + span2.start, + span2.end - 1, + ) pred_instances.add(pred_rel) if span1.label_ == span2.label_: pred_per_type[span1.label_].add(pred_rel) @@ -511,11 +539,10 @@ def score_clusters( # Score for all labels score.score_set(pred_instances, gold_instances) # Assemble final result - final_scores = { + final_scores: Dict[str, Optional[float]] = { f"{attr}_p": None, f"{attr}_r": None, f"{attr}_f": None, - } if include_label: final_scores[f"{attr}_per_type"] = None From cea40c9d7b0f2955eb79463351d15470c2404112 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 25 May 2022 13:34:09 +0200 Subject: [PATCH 146/188] fix types + black formatting --- spacy/ml/models/coref.py | 1 + spacy/ml/models/coref_util.py | 12 ++++++------ spacy/pipeline/coref.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index d5961949888..96fad801982 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -56,6 +56,7 @@ def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bo def backprop(args: ArgsKwargs) -> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) + assert isinstance(gradients, Floats2d) return [gradients] return ArgsKwargs(args=(word_features,), kwargs={}), backprop diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 8d0ff7bb02c..dc9366a613a 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -1,6 +1,6 @@ +from typing import List, Tuple, Set, Dict, cast from thinc.types import Ints2d from spacy.tokens import Doc -from typing import List, Tuple, Set # type alias to make writing this less tedious MentionClusters = List[List[Tuple[int, int]]] @@ -111,9 +111,9 @@ def select_non_crossing_spans( Nested spans are allowed. """ # ported from Model._extract_top_spans - selected = [] - start_to_max_end = {} - end_to_min_start = {} + selected: List[int] = [] + start_to_max_end: Dict[int, int] = {} + end_to_min_start: Dict[int, int] = {} for idx in idxs: if len(selected) >= limit or idx > len(starts): @@ -188,7 +188,7 @@ def create_gold_scores( """ # make a mapping of mentions to cluster id # id is not important but equality will be - ment2cid = {} + ment2cid: Dict[Tuple[int, int], int] = {} for cid, cluster in enumerate(clusters): for ment in cluster: ment2cid[ment] = cid @@ -196,7 +196,7 @@ def create_gold_scores( ll = len(ments) out = [] # The .tolist() call is necessary with cupy but not numpy - mentuples = [tuple(mm.tolist()) for mm in ments] + mentuples = [cast(Tuple[int, int], tuple(mm.tolist())) for mm in ments] for ii, ment in enumerate(mentuples): if ment not in ment2cid: # this is not in a cluster so it has no antecedent diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 76e790896dd..ebdb3b9d07b 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -259,7 +259,7 @@ def get_loss( span_idxs = create_head_span_idxs(ops, len(example.predicted)) gscores = create_gold_scores(span_idxs, clusters) # TODO fix type here. This is bools but asarray2f wants ints. - gscores = ops.asarray2f(gscores) + gscores = ops.asarray2f(gscores) # type: ignore # top_gscores = xp.take_along_axis(gscores, cidx, axis=1) top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) # now add the placeholder From aa2eb2789cb3aa3cd9c1c25c291c639582e7a201 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 25 May 2022 13:50:54 +0200 Subject: [PATCH 147/188] small type fixes --- spacy/pipeline/span_predictor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index 12ea6611cc2..d21a45edbdb 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -96,7 +96,7 @@ def __init__( self.input_prefix = input_prefix self.output_prefix = output_prefix - self.cfg = {} + self.cfg: Dict[str, Any] = {} def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: # for now pretend there's just one doc @@ -205,7 +205,7 @@ def get_loss( ops = self.model.ops # NOTE This is doing fake batching, and should always get a list of one example - assert len(examples) == 1, "Only fake batching is supported." + assert len(list(examples)) == 1, "Only fake batching is supported." # starts and ends are gold starts and ends (Ints1d) # span_scores is a Floats3d. What are the axes? mention x token x start/end for eg in examples: From 196886bbca22ace77aa67f08c24f9a683ccfdb32 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 8 Jun 2022 20:03:41 +0900 Subject: [PATCH 148/188] Fix coref size inference (#10916) * Add explicit tok2vec_size parameter in clusterer * Add tok2vec size to span predictor config * Minor fixes --- spacy/ml/models/coref.py | 24 ++++++++++-------------- spacy/ml/models/span_predictor.py | 9 ++------- spacy/pipeline/coref.py | 1 + spacy/pipeline/span_predictor.py | 1 + 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 96fad801982..c7fb2ba242d 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -19,19 +19,15 @@ def build_wl_coref_model( # pairs to keep per mention after rough scoring antecedent_limit: int = 50, antecedent_batch_size: int = 512, + tok2vec_size: int = 768, # tok2vec size ): # TODO add model return types - # TODO fix this - try: - dim = tok2vec.get_dim("nO") - except ValueError: - # happens with transformer listener - dim = 768 + # dim = tok2vec.maybe_get_dim("n0") with Model.define_operators({">>": chain}): coref_clusterer = PyTorchWrapper( CorefClusterer( - dim, + tok2vec_size, distance_embedding_size, hidden_size, depth, @@ -56,7 +52,7 @@ def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bo def backprop(args: ArgsKwargs) -> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) - assert isinstance(gradients, Floats2d) + # assert isinstance(gradients, Floats2d) return [gradients] return ArgsKwargs(args=(word_features,), kwargs={}), backprop @@ -89,7 +85,7 @@ class CorefClusterer(torch.nn.Module): def __init__( self, - dim: int, # tok2vec size + dim: int, dist_emb_size: int, hidden_size: int, n_layers: int, @@ -109,19 +105,19 @@ def __init__( """ self.dropout = torch.nn.Dropout(dropout) self.batch_size = batch_size - # Modules self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) + pair_emb = dim * 3 + self.pw.shape - self.a_scorer = AnaphoricityScorer(pair_emb, hidden_size, n_layers, dropout) + self.a_scorer = AnaphoricityScorer( + pair_emb, hidden_size, n_layers, dropout + ) self.lstm = torch.nn.LSTM( input_size=dim, hidden_size=dim, batch_first=True, ) + self.rough_scorer = RoughScorer(dim, dropout, roughk) - self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) - pair_emb = dim * 3 + self.pw.shape - self.a_scorer = AnaphoricityScorer(pair_emb, hidden_size, n_layers, dropout) def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index a8c4d1aaa7e..7962e4157b1 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -13,6 +13,7 @@ @registry.architectures("spacy.SpanPredictor.v1") def build_span_predictor( tok2vec: Model[List[Doc], List[Floats2d]], + tok2vec_size: int = 768, hidden_size: int = 1024, distance_embedding_size: int = 64, conv_channels: int = 4, @@ -21,17 +22,11 @@ def build_span_predictor( prefix: str = "coref_head_clusters", ): # TODO add model return types - # TODO fix this - try: - dim = tok2vec.get_dim("nO") - except ValueError: - # happens with transformer listener - dim = 768 with Model.define_operators({">>": chain, "&": tuplify}): span_predictor = PyTorchWrapper( SpanPredictor( - dim, + tok2vec_size, hidden_size, distance_embedding_size, conv_channels, diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index ebdb3b9d07b..96dc80f534d 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -30,6 +30,7 @@ default_config = """ [model] @architectures = "spacy.Coref.v1" +tok2vec_size = 768 distance_embedding_size = 20 hidden_size = 1024 depth = 1 diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index d21a45edbdb..23539dce989 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -24,6 +24,7 @@ default_span_predictor_config = """ [model] @architectures = "spacy.SpanPredictor.v1" +tok2vec_size = 768 hidden_size = 1024 distance_embedding_size = 64 conv_channels = 4 From 16894e665d9d61d3b76161fceb03bbaf2d6d24ea Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 22 Jun 2022 16:05:52 +0900 Subject: [PATCH 149/188] Refactor Coval Scoring code (#10875) * Move coref scoring code to scorer.py Includes some renames to make names less generic. * Refactor coval code to remove ternary expressions * Black formatting * Add header * Make scorers into registered scorers * Small test fixes * Skip coref tests when torch not present Coref can't be loaded without Torch, so nothing works. * Fix remaining type issues Some of this just involves ignoring types in thorny areas. Two main issues: 1. Some things have weird types due to indirection/ argskwargs 2. xp2torch return type seems to have changed at some point * Update spacy/scorer.py Co-authored-by: kadarakos * Small changes from review * Be specific about the ValueError * Type fix Co-authored-by: kadarakos --- licenses/3rd_party_licenses.txt | 33 +++++ spacy/coref_scorer.py | 124 ---------------- spacy/ml/models/coref.py | 4 +- spacy/ml/models/coref_util.py | 17 --- spacy/ml/models/span_predictor.py | 15 +- spacy/pipeline/coref.py | 49 +++---- spacy/pipeline/span_predictor.py | 61 ++++---- spacy/scorer.py | 220 +++++++++++++++++++++++++++++ spacy/tests/pipeline/test_coref.py | 20 ++- spacy/tests/test_models.py | 3 +- 10 files changed, 321 insertions(+), 225 deletions(-) delete mode 100644 spacy/coref_scorer.py diff --git a/licenses/3rd_party_licenses.txt b/licenses/3rd_party_licenses.txt index d58da9c4a6b..c605c40b95c 100644 --- a/licenses/3rd_party_licenses.txt +++ b/licenses/3rd_party_licenses.txt @@ -127,3 +127,36 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + +coval +----- + +* Files: scorer.py + +The implementations of ClusterEvaluator, lea, get_cluster_info, and +get_markable_assignments are adapted from coval, which is distributed +under the following license: + +The MIT License (MIT) + +Copyright 2018 Nafise Sadat Moosavi (ns.moosavi at gmail dot com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/spacy/coref_scorer.py b/spacy/coref_scorer.py deleted file mode 100644 index 981b1cf03f8..00000000000 --- a/spacy/coref_scorer.py +++ /dev/null @@ -1,124 +0,0 @@ -# copied from coval -# https://github.com/ns-moosavi/coval - - -def get_cluster_info(predicted_clusters, gold_clusters): - p2g = get_markable_assignments(predicted_clusters, gold_clusters) - g2p = get_markable_assignments(gold_clusters, predicted_clusters) - # this is the data format used as input by the evaluator - return (gold_clusters, predicted_clusters, g2p, p2g) - - -def get_markable_assignments(in_clusters, out_clusters): - markable_cluster_ids = {} - out_dic = {} - for cluster_id, cluster in enumerate(out_clusters): - for m in cluster: - out_dic[m] = cluster_id - - for cluster in in_clusters: - for im in cluster: - for om in out_dic: - if im == om: - markable_cluster_ids[im] = out_dic[om] - break - - return markable_cluster_ids - - -def f1(p_num, p_den, r_num, r_den, beta=1): - p = 0 if p_den == 0 else p_num / float(p_den) - r = 0 if r_den == 0 else r_num / float(r_den) - return 0 if p + r == 0 else (1 + beta * beta) * p * r / (beta * beta * p + r) - - -class Evaluator: - def __init__(self, metric, beta=1, keep_aggregated_values=False): - self.p_num = 0 - self.p_den = 0 - self.r_num = 0 - self.r_den = 0 - self.metric = metric - self.beta = beta - self.keep_aggregated_values = keep_aggregated_values - - if keep_aggregated_values: - self.aggregated_p_num = [] - self.aggregated_p_den = [] - self.aggregated_r_num = [] - self.aggregated_r_den = [] - - def update(self, coref_info): - ( - key_clusters, - sys_clusters, - key_mention_sys_cluster, - sys_mention_key_cluster, - ) = coref_info - - pn, pd = self.metric(sys_clusters, key_clusters, sys_mention_key_cluster) - rn, rd = self.metric(key_clusters, sys_clusters, key_mention_sys_cluster) - self.p_num += pn - self.p_den += pd - self.r_num += rn - self.r_den += rd - - if self.keep_aggregated_values: - self.aggregated_p_num.append(pn) - self.aggregated_p_den.append(pd) - self.aggregated_r_num.append(rn) - self.aggregated_r_den.append(rd) - - def get_f1(self): - return f1(self.p_num, self.p_den, self.r_num, self.r_den, beta=self.beta) - - def get_recall(self): - return 0 if self.r_num == 0 else self.r_num / float(self.r_den) - - def get_precision(self): - return 0 if self.p_num == 0 else self.p_num / float(self.p_den) - - def get_prf(self): - return self.get_precision(), self.get_recall(), self.get_f1() - - def get_counts(self): - return self.p_num, self.p_den, self.r_num, self.r_den - - def get_aggregated_values(self): - return ( - self.aggregated_p_num, - self.aggregated_p_den, - self.aggregated_r_num, - self.aggregated_r_den, - ) - - -def lea(input_clusters, output_clusters, mention_to_gold): - num, den = 0, 0 - - for c in input_clusters: - if len(c) == 1: - all_links = 1 - if ( - c[0] in mention_to_gold - and len(output_clusters[mention_to_gold[c[0]]]) == 1 - ): - common_links = 1 - else: - common_links = 0 - else: - common_links = 0 - all_links = len(c) * (len(c) - 1) / 2.0 - for i, m in enumerate(c): - if m in mention_to_gold: - for m2 in c[i + 1 :]: - if ( - m2 in mention_to_gold - and mention_to_gold[m] == mention_to_gold[m2] - ): - common_links += 1 - - num += len(c) * common_links / float(all_links) - den += len(c) - - return num, den diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index c7fb2ba242d..a8c880a3961 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -49,10 +49,10 @@ def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bo X = X[0] word_features = xp2torch(X, requires_grad=is_train) - def backprop(args: ArgsKwargs) -> List[Floats2d]: + # TODO fix or remove type annotations + def backprop(args: ArgsKwargs): #-> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) - # assert isinstance(gradients, Floats2d) return [gradients] return ArgsKwargs(args=(word_features,), kwargs={}), backprop diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index dc9366a613a..a004a69d73c 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -32,23 +32,6 @@ def get_sentence_ids(doc): return out -def doc2clusters(doc: Doc, prefix=DEFAULT_CLUSTER_PREFIX) -> MentionClusters: - """Given a doc, give the mention clusters. - - This is useful for scoring. - """ - out = [] - for name, val in doc.spans.items(): - if not name.startswith(prefix): - continue - - cluster = [] - for mention in val: - cluster.append((mention.start, mention.end)) - out.append(cluster) - return out - - # from model.py, refactored to be non-member def get_predicted_antecedents(xp, antecedent_idx, antecedent_scores): """Get the ID of the antecedent for each span. -1 if no antecedent.""" diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 7962e4157b1..378b79e9be2 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -43,23 +43,24 @@ def build_span_predictor( def convert_span_predictor_inputs( - model: Model, X: Tuple[Ints1d, Tuple[Floats2d, Ints1d]], is_train: bool + model: Model, X: Tuple[List[Floats2d], Tuple[List[Ints1d], List[Ints1d]]], is_train: bool ): tok2vec, (sent_ids, head_ids) = X # Normally we should use the input is_train, but for these two it's not relevant - - def backprop(args: ArgsKwargs) -> List[Floats2d]: + # TODO fix the type here, or remove it + def backprop(args: ArgsKwargs): #-> Tuple[List[Floats2d], None]: gradients = torch2xp(args.args[1]) + # The sent_ids and head_ids are None because no gradients return [[gradients], None] word_features = xp2torch(tok2vec[0], requires_grad=is_train) - sent_ids = xp2torch(sent_ids[0], requires_grad=False) + sent_ids_tensor = xp2torch(sent_ids[0], requires_grad=False) if not head_ids[0].size: - head_ids = torch.empty(size=(0,)) + head_ids_tensor = torch.empty(size=(0,)) else: - head_ids = xp2torch(head_ids[0], requires_grad=False) + head_ids_tensor = xp2torch(head_ids[0], requires_grad=False) - argskwargs = ArgsKwargs(args=(sent_ids, word_features, head_ids), kwargs={}) + argskwargs = ArgsKwargs(args=(sent_ids_tensor, word_features, head_ids_tensor), kwargs={}) return argskwargs, backprop diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 96dc80f534d..cd07f80e8f1 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -13,6 +13,7 @@ from ..errors import Errors from ..tokens import Doc from ..vocab import Vocab +from ..util import registry from ..ml.models.coref_util import ( create_gold_scores, @@ -21,10 +22,9 @@ get_clusters_from_doc, get_predicted_clusters, DEFAULT_CLUSTER_PREFIX, - doc2clusters, ) -from ..coref_scorer import Evaluator, get_cluster_info, lea +from ..scorer import Scorer default_config = """ @@ -57,7 +57,14 @@ """ DEFAULT_COREF_MODEL = Config().from_str(default_config)["model"] -DEFAULT_CLUSTERS_PREFIX = "coref_clusters" + +def coref_scorer(examples: Iterable[Example], **kwargs) -> Dict[str, Any]: + return Scorer.score_coref_clusters(examples, **kwargs) + + +@registry.scorers("spacy.coref_scorer.v1") +def make_coref_scorer(): + return coref_scorer @Language.factory( @@ -67,6 +74,7 @@ default_config={ "model": DEFAULT_COREF_MODEL, "span_cluster_prefix": DEFAULT_CLUSTER_PREFIX, + "scorer": {"@scorers": "spacy.coref_scorer.v1"}, }, default_score_weights={"coref_f": 1.0, "coref_p": None, "coref_r": None}, ) @@ -74,12 +82,13 @@ def make_coref( nlp: Language, name: str, model, - span_cluster_prefix: str = "coref", + scorer: Optional[Callable], + span_cluster_prefix: str, ) -> "CoreferenceResolver": """Create a CoreferenceResolver component.""" return CoreferenceResolver( - nlp.vocab, model, name, span_cluster_prefix=span_cluster_prefix + nlp.vocab, model, name, span_cluster_prefix=span_cluster_prefix, scorer=scorer ) @@ -96,7 +105,8 @@ def __init__( name: str = "coref", *, span_mentions: str = "coref_mentions", - span_cluster_prefix: str, + span_cluster_prefix: str = DEFAULT_CLUSTER_PREFIX, + scorer: Optional[Callable] = coref_scorer, ) -> None: """Initialize a coreference resolution component. @@ -118,7 +128,8 @@ def __init__( self.span_cluster_prefix = span_cluster_prefix self._rehearsal_model = None - self.cfg: Dict[str, Any] = {} + self.cfg: Dict[str, Any] = {"span_cluster_prefix": span_cluster_prefix} + self.scorer = scorer def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: """Apply the pipeline's model to a batch of docs, without modifying them. @@ -276,7 +287,6 @@ def get_loss( log_marg = ops.softmax(score_matrix + ops.xp.log(top_gscores), axis=1) log_norm = ops.softmax(score_matrix, axis=1) grad = log_norm - log_marg - # gradients.append((grad, cidx)) loss = float((grad**2).sum()) return loss, grad @@ -306,26 +316,3 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) - - def score(self, examples, **kwargs): - """Score a batch of examples using LEA. - For details on how LEA works and why to use it see the paper: - Which Coreference Evaluation Metric Do You Trust? A Proposal for a Link-based Entity Aware Metric - Moosavi and Strube, 2016 - https://api.semanticscholar.org/CorpusID:17606580 - """ - - evaluator = Evaluator(lea) - - for ex in examples: - p_clusters = doc2clusters(ex.predicted, self.span_cluster_prefix) - g_clusters = doc2clusters(ex.reference, self.span_cluster_prefix) - cluster_info = get_cluster_info(p_clusters, g_clusters) - evaluator.update(cluster_info) - - score = { - "coref_f": evaluator.get_f1(), - "coref_p": evaluator.get_precision(), - "coref_r": evaluator.get_recall(), - } - return score diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index 23539dce989..d7e96a4b294 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -5,20 +5,19 @@ from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy from thinc.api import set_dropout_rate, to_categorical from itertools import islice -from statistics import mean from .trainable_pipe import TrainablePipe from ..language import Language from ..training import Example, validate_examples, validate_get_examples from ..errors import Errors -from ..scorer import Scorer +from ..scorer import Scorer, doc2clusters from ..tokens import Doc from ..vocab import Vocab +from ..util import registry from ..ml.models.coref_util import ( MentionClusters, DEFAULT_CLUSTER_PREFIX, - doc2clusters, ) default_span_predictor_config = """ @@ -52,6 +51,15 @@ DEFAULT_SPAN_PREDICTOR_MODEL = Config().from_str(default_span_predictor_config)["model"] +def span_predictor_scorer(examples: Iterable[Example], **kwargs) -> Dict[str, Any]: + return Scorer.score_span_predictions(examples, **kwargs) + + +@registry.scorers("spacy.span_predictor_scorer.v1") +def make_span_predictor_scorer(): + return span_predictor_scorer + + @Language.factory( "span_predictor", assigns=["doc.spans"], @@ -60,6 +68,7 @@ "model": DEFAULT_SPAN_PREDICTOR_MODEL, "input_prefix": "coref_head_clusters", "output_prefix": "coref_clusters", + "scorer": {"@scorers": "spacy.span_predictor_scorer.v1"}, }, default_score_weights={"span_accuracy": 1.0}, ) @@ -69,10 +78,16 @@ def make_span_predictor( model, input_prefix: str = "coref_head_clusters", output_prefix: str = "coref_clusters", + scorer: Optional[Callable] = span_predictor_scorer, ) -> "SpanPredictor": """Create a SpanPredictor component.""" return SpanPredictor( - nlp.vocab, model, name, input_prefix=input_prefix, output_prefix=output_prefix + nlp.vocab, + model, + name, + input_prefix=input_prefix, + output_prefix=output_prefix, + scorer=scorer, ) @@ -90,6 +105,7 @@ def __init__( *, input_prefix: str = "coref_head_clusters", output_prefix: str = "coref_clusters", + scorer: Optional[Callable] = span_predictor_scorer, ) -> None: self.vocab = vocab self.model = model @@ -97,7 +113,10 @@ def __init__( self.input_prefix = input_prefix self.output_prefix = output_prefix - self.cfg: Dict[str, Any] = {} + self.scorer = scorer + self.cfg: Dict[str, Any] = { + "output_prefix": output_prefix, + } def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: # for now pretend there's just one doc @@ -255,35 +274,3 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) - - def score(self, examples, **kwargs): - """ - Evaluate on reconstructing the correct spans around - gold heads. - """ - scores = [] - xp = self.model.ops.xp - for eg in examples: - starts = [] - ends = [] - pred_starts = [] - pred_ends = [] - ref = eg.reference - pred = eg.predicted - for key, gold_sg in ref.spans.items(): - if key.startswith(self.output_prefix): - pred_sg = pred.spans[key] - for gold_mention, pred_mention in zip(gold_sg, pred_sg): - starts.append(gold_mention.start) - ends.append(gold_mention.end) - pred_starts.append(pred_mention.start) - pred_ends.append(pred_mention.end) - - starts = xp.asarray(starts) - ends = xp.asarray(ends) - pred_starts = xp.asarray(pred_starts) - pred_ends = xp.asarray(pred_ends) - correct = (starts == pred_starts) * (ends == pred_ends) - accuracy = correct.mean() - scores.append(float(accuracy)) - return {"span_accuracy": mean(scores)} diff --git a/spacy/scorer.py b/spacy/scorer.py index 8ee6294ad62..14b4b2a7956 100644 --- a/spacy/scorer.py +++ b/spacy/scorer.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import numpy as np from collections import defaultdict +from statistics import mean from .training import Example from .tokens import Token, Doc, Span @@ -9,6 +10,7 @@ from .util import get_lang_class, SimpleFrozenList from .morphology import Morphology + if TYPE_CHECKING: # This lets us add type hints for mypy etc. without causing circular imports from .language import Language # noqa: F401 @@ -873,6 +875,66 @@ def score_deps( f"{attr}_las_per_type": None, } + @staticmethod + def score_coref_clusters(examples: Iterable[Example], **cfg): + """Score a batch of examples using LEA. + + For details on how LEA works and why to use it see the paper: + Which Coreference Evaluation Metric Do You Trust? A Proposal for a Link-based Entity Aware Metric + Moosavi and Strube, 2016 + https://api.semanticscholar.org/CorpusID:17606580 + """ + + span_cluster_prefix = cfg["span_cluster_prefix"] + + evaluator = ClusterEvaluator(lea) + + for ex in examples: + p_clusters = doc2clusters(ex.predicted, span_cluster_prefix) + g_clusters = doc2clusters(ex.reference, span_cluster_prefix) + cluster_info = get_cluster_info(p_clusters, g_clusters) + evaluator.update(cluster_info) + + score = { + "coref_f": evaluator.get_f1(), + "coref_p": evaluator.get_precision(), + "coref_r": evaluator.get_recall(), + } + return score + + @staticmethod + def score_span_predictions(examples: Iterable[Example], **cfg): + """Evaluate reconstruction of the correct spans from gold heads. + """ + scores = [] + output_prefix = cfg["output_prefix"] + for eg in examples: + starts = [] + ends = [] + pred_starts = [] + pred_ends = [] + ref = eg.reference + pred = eg.predicted + for key, gold_sg in ref.spans.items(): + if key.startswith(output_prefix): + pred_sg = pred.spans[key] + for gold_mention, pred_mention in zip(gold_sg, pred_sg): + starts.append(gold_mention.start) + ends.append(gold_mention.end) + pred_starts.append(pred_mention.start) + pred_ends.append(pred_mention.end) + + + # see how many are perfect + cs = [a == b for a, b in zip(starts, pred_starts)] + ce = [a == b for a, b in zip(ends, pred_ends)] + correct = [int(a and b) for a, b in zip(cs, ce)] + accuracy = sum(correct) / len(correct) + + scores.append(float(accuracy)) + out_key = f"span_{output_prefix}_accuracy" + return {out_key: mean(scores)} + def get_ner_prf(examples: Iterable[Example], **kwargs) -> Dict[str, Any]: """Compute micro-PRF and per-entity PRF scores for a sequence of examples.""" @@ -1143,3 +1205,161 @@ def _auc(x, y): # regular numpy.ndarray instances. area = area.dtype.type(area) return area + + +# The following implementations of get_cluster_info(), get_markable_assignments, +# and ClusterEvaluator are adapted from coval, which is distributed under the +# MIT License. +# Copyright 2018 Nafise Sadat Moosavi +# See licenses/3rd_party_licenses.txt +def get_cluster_info(predicted_clusters, gold_clusters): + p2g = get_markable_assignments(predicted_clusters, gold_clusters) + g2p = get_markable_assignments(gold_clusters, predicted_clusters) + # this is the data format used as input by the evaluator + return (gold_clusters, predicted_clusters, g2p, p2g) + + +def get_markable_assignments(in_clusters, out_clusters): + markable_cluster_ids = {} + out_dic = {} + for cluster_id, cluster in enumerate(out_clusters): + for m in cluster: + out_dic[m] = cluster_id + + for cluster in in_clusters: + for im in cluster: + for om in out_dic: + if im == om: + markable_cluster_ids[im] = out_dic[om] + break + + return markable_cluster_ids + + +class ClusterEvaluator: + def __init__(self, metric, beta=1, keep_aggregated_values=False): + self.p_num = 0 + self.p_den = 0 + self.r_num = 0 + self.r_den = 0 + self.metric = metric + self.beta = beta + self.keep_aggregated_values = keep_aggregated_values + + if keep_aggregated_values: + self.aggregated_p_num = [] + self.aggregated_p_den = [] + self.aggregated_r_num = [] + self.aggregated_r_den = [] + + def update(self, coref_info): + ( + key_clusters, + sys_clusters, + key_mention_sys_cluster, + sys_mention_key_cluster, + ) = coref_info + + pn, pd = self.metric(sys_clusters, key_clusters, sys_mention_key_cluster) + rn, rd = self.metric(key_clusters, sys_clusters, key_mention_sys_cluster) + self.p_num += pn + self.p_den += pd + self.r_num += rn + self.r_den += rd + + if self.keep_aggregated_values: + self.aggregated_p_num.append(pn) + self.aggregated_p_den.append(pd) + self.aggregated_r_num.append(rn) + self.aggregated_r_den.append(rd) + + def f1(self, p_num, p_den, r_num, r_den, beta=1): + p = 0 + if p_den != 0: + p = p_num / float(p_den) + r = 0 + if r_den != 0: + r = r_num / float(r_den) + + if p + r == 0: + return 0 + + return (1 + beta * beta) * p * r / (beta * beta * p + r) + + def get_f1(self): + return self.f1(self.p_num, self.p_den, self.r_num, self.r_den, beta=self.beta) + + def get_recall(self): + if self.r_num == 0: + return 0 + + return self.r_num / float(self.r_den) + + def get_precision(self): + if self.p_num == 0: + return 0 + + return self.p_num / float(self.p_den) + + def get_prf(self): + return self.get_precision(), self.get_recall(), self.get_f1() + + def get_counts(self): + return self.p_num, self.p_den, self.r_num, self.r_den + + def get_aggregated_values(self): + return ( + self.aggregated_p_num, + self.aggregated_p_den, + self.aggregated_r_num, + self.aggregated_r_den, + ) + + +def lea(input_clusters, output_clusters, mention_to_gold): + num, den = 0, 0 + + for c in input_clusters: + if len(c) == 1: + all_links = 1 + if ( + c[0] in mention_to_gold + and len(output_clusters[mention_to_gold[c[0]]]) == 1 + ): + common_links = 1 + else: + common_links = 0 + else: + common_links = 0 + all_links = len(c) * (len(c) - 1) / 2.0 + for i, m in enumerate(c): + if m in mention_to_gold: + for m2 in c[i + 1 :]: + if ( + m2 in mention_to_gold + and mention_to_gold[m] == mention_to_gold[m2] + ): + common_links += 1 + + num += len(c) * common_links / float(all_links) + den += len(c) + + return num, den + + +# This is coref related, but not from coval. +def doc2clusters(doc: Doc, prefix: str) -> List[List[Tuple[int, int]]]: + """Given a doc, give the mention clusters. + + This is used for scoring. + """ + out = [] + for name, val in doc.spans.items(): + if not name.startswith(prefix): + continue + + cluster = [] + for mention in val: + cluster.append((mention.start, mention.end)) + out.append(cluster) + return out diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 25de6e35634..53f0b201169 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -5,24 +5,26 @@ from spacy.training import Example from spacy.lang.en import English from spacy.tests.util import make_tempdir -from spacy.pipeline.coref import DEFAULT_CLUSTERS_PREFIX from spacy.ml.models.coref_util import ( + DEFAULT_CLUSTER_PREFIX, select_non_crossing_spans, get_sentence_ids, ) +from thinc.util import has_torch + # fmt: off TRAIN_DATA = [ ( "Yes, I noticed that many friends around me received it. It seems that almost everyone received this SMS.", { "spans": { - f"{DEFAULT_CLUSTERS_PREFIX}_1": [ + f"{DEFAULT_CLUSTER_PREFIX}_1": [ (5, 6, "MENTION"), # I (40, 42, "MENTION"), # me ], - f"{DEFAULT_CLUSTERS_PREFIX}_2": [ + f"{DEFAULT_CLUSTER_PREFIX}_2": [ (52, 54, "MENTION"), # it (95, 103, "MENTION"), # this SMS ] @@ -45,18 +47,20 @@ def snlp(): return en +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_add_pipe(nlp): nlp.add_pipe("coref") assert nlp.pipe_names == ["coref"] +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_not_initialized(nlp): nlp.add_pipe("coref") text = "She gave me her pen." - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="E109"): nlp(text) - +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized(nlp): nlp.add_pipe("coref") nlp.initialize() @@ -68,15 +72,16 @@ def test_initialized(nlp): assert len(v) <= 15 +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized_short(nlp): nlp.add_pipe("coref") nlp.initialize() assert nlp.pipe_names == ["coref"] text = "Hi there" doc = nlp(text) - print(doc.spans) +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_coref_serialization(nlp): # Test that the coref component can be serialized nlp.add_pipe("coref", last=True) @@ -101,6 +106,7 @@ def test_coref_serialization(nlp): # assert spans_result == spans_result2 +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_overfitting_IO(nlp): # Simple test to try and quickly overfit the senter - ensuring the ML models work correctly train_examples = [] @@ -147,6 +153,7 @@ def test_overfitting_IO(nlp): # assert_equal(batch_deps_1, no_batch_deps) +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_crossing_spans(): starts = [6, 10, 0, 1, 0, 1, 0, 1, 2, 2, 2] ends = [12, 12, 2, 3, 3, 4, 4, 4, 3, 4, 5] @@ -158,6 +165,7 @@ def test_crossing_spans(): guess = sorted(guess) assert gold == guess +@pytest.mark.skipif(not has_torch, reason="Torch not available") def test_sentence_map(snlp): doc = snlp("I like text. This is text.") sm = get_sentence_ids(doc) diff --git a/spacy/tests/test_models.py b/spacy/tests/test_models.py index 794f9ca8797..b3ce46e3405 100644 --- a/spacy/tests/test_models.py +++ b/spacy/tests/test_models.py @@ -7,8 +7,9 @@ import numpy from spacy.ml.models import build_Tok2Vec_model, MultiHashEmbed, MaxoutWindowEncoder from spacy.ml.models import build_bow_text_classifier, build_simple_cnn_text_classifier +from spacy.ml.models import build_spancat_model if has_torch: - from spacy.ml.models import build_spancat_model, build_wl_coref_model + from spacy.ml.models import build_wl_coref_model, build_span_predictor from spacy.ml.staticvectors import StaticVectors from spacy.ml.extract_spans import extract_spans, _get_span_indices from spacy.lang.en import English From af6d5ae2fee7a2a95192720a22fb4c4864886a7f Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 28 Jun 2022 19:04:24 +0900 Subject: [PATCH 150/188] Initial test of mismatched tokenization This runs, but the results are nonsense because the indices are off. --- spacy/tests/pipeline/test_coref.py | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 53f0b201169..358da6b0321 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -152,6 +152,62 @@ def test_overfitting_IO(nlp): # assert_equal(batch_deps_1, batch_deps_2) # assert_equal(batch_deps_1, no_batch_deps) +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_tokenization_mismatch(nlp): + train_examples = [] + for text, annot in TRAIN_DATA: + eg = Example.from_dict(nlp.make_doc(text), annot) + ref = eg.reference + char_spans = {} + for key, cluster in ref.spans.items(): + char_spans[key] = [] + for span in cluster: + char_spans[key].append( (span[0].idx, span[-1].idx + len(span[-1])) ) + with ref.retokenize() as retokenizer: + # merge "many friends" + retokenizer.merge(ref[5:7]) + + # Note this works because it's the same doc and we know the keys + for key, _ in ref.spans.items(): + spans = char_spans[key] + ref.spans[key] = [ref.char_span(*span) for span in spans] + + train_examples.append(eg) + + nlp.add_pipe("coref") + optimizer = nlp.initialize() + test_text = TRAIN_DATA[0][0] + doc = nlp(test_text) + + for i in range(15): + losses = {} + nlp.update(train_examples, sgd=optimizer, losses=losses) + doc = nlp(test_text) + print(i, doc.spans) + + # test the trained model + doc = nlp(test_text) + + # Also test the results are still the same after IO + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = util.load_model_from_path(tmp_dir) + doc2 = nlp2(test_text) + + # Make sure that running pipe twice, or comparing to call, always amounts to the same predictions + texts = [ + test_text, + "I noticed many friends around me", + "They received it. They received the SMS.", + ] + + # save the docs so they don't get garbage collected + docs = list(nlp.pipe(texts)) + batch_deps_1 = [doc.spans for doc in docs] + docs = list(nlp.pipe(texts)) + batch_deps_2 = [doc.spans for doc in docs] + docs = [nlp(text) for text in texts] + no_batch_deps = [doc.spans for doc in docs] @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_crossing_spans(): From ef5762d78eca97f487efd93dfeb233b4fcea0d30 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 28 Jun 2022 19:06:13 +0900 Subject: [PATCH 151/188] Bad hack to get tests to run This changes the tok2vec size in coref to hardcoded 64 to get tests to run. This should be reverted and hopefully replaced with proper shape inference. --- spacy/ml/models/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index a8c880a3961..5fa75800eaf 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -22,7 +22,7 @@ def build_wl_coref_model( tok2vec_size: int = 768, # tok2vec size ): # TODO add model return types - # dim = tok2vec.maybe_get_dim("n0") + tok2vec_size = 64 with Model.define_operators({">>": chain}): coref_clusterer = PyTorchWrapper( From d1ff933e9b77b723b7ed326a6c339340fd47d318 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 28 Jun 2022 19:15:33 +0900 Subject: [PATCH 152/188] Test works This may not be done yet, as the test is just for consistency, and not overfitting correctly yet. --- spacy/errors.py | 1 + spacy/ml/models/coref_util.py | 12 +++++---- spacy/pipeline/coref.py | 14 +++++++++- spacy/tests/pipeline/test_coref.py | 41 +++++++++++++++++------------- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/spacy/errors.py b/spacy/errors.py index c82ffe8824b..837bfd740e6 100644 --- a/spacy/errors.py +++ b/spacy/errors.py @@ -919,6 +919,7 @@ class Errors(metaclass=ErrorsWithCodes): E1035 = ("Token index {i} out of bounds ({length})") E1036 = ("Cannot index into NoneNode") E1037 = ("Invalid attribute value '{attr}'.") + E1038 = ("Misalignment in coref. Head token has no match in training doc.") # Deprecated model shortcuts, only used in errors and warnings diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index a004a69d73c..bd577e65f81 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -143,16 +143,18 @@ def create_head_span_idxs(ops, doclen: int): def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: - """Given a Doc, convert the cluster spans to simple int tuple lists.""" + """Given a Doc, convert the cluster spans to simple int tuple lists. The + ints are char spans, to be tokenization independent. + """ out = [] for key, val in doc.spans.items(): cluster = [] for span in val: - # TODO check that there isn't an off-by-one error here - # cluster.append((span.start, span.end)) - # TODO This conversion should be happening earlier in processing + head_i = span.root.i - cluster.append((head_i, head_i + 1)) + head = doc[head_i] + char_span = (head.idx, head.idx + len(head)) + cluster.append(char_span) # don't want duplicates cluster = list(set(cluster)) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index cd07f80e8f1..630502f6d73 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -267,7 +267,19 @@ def get_loss( example = list(examples)[0] cidx = mention_idx - clusters = get_clusters_from_doc(example.reference) + clusters_by_char = get_clusters_from_doc(example.reference) + # convert to token clusters, and give up if necessary + clusters = [] + for cluster in clusters_by_char: + cc = [] + for start_char, end_char in cluster: + span = example.predicted.char_span(start_char, end_char) + if span is None: + # TODO log more details + raise IndexError(Errors.E1038) + cc.append( (span.start, span.end) ) + clusters.append(cc) + span_idxs = create_head_span_idxs(ops, len(example.predicted)) gscores = create_gold_scores(span_idxs, clusters) # TODO fix type here. This is bools but asarray2f wants ints. diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 358da6b0321..584db99b889 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -34,6 +34,15 @@ ] # fmt: on +def spans2ints(doc): + """Convert doc.spans to nested list of ints for comparison. + + This is useful for checking consistency of predictions. + """ + out = [] + for key, cluster in doc.spans.items(): + out.append( [(ss.start, ss.end) for ss in cluster] ) + return out @pytest.fixture def nlp(): @@ -108,7 +117,7 @@ def test_coref_serialization(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_overfitting_IO(nlp): - # Simple test to try and quickly overfit the senter - ensuring the ML models work correctly + # Simple test to try and quickly overfit - ensuring the ML models work correctly train_examples = [] for text, annot in TRAIN_DATA: train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) @@ -117,25 +126,21 @@ def test_overfitting_IO(nlp): optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) - print("BEFORE", doc.spans) - for i in range(5): + # Needs ~12 epochs to converge + for i in range(15): losses = {} nlp.update(train_examples, sgd=optimizer, losses=losses) doc = nlp(test_text) - print(i, doc.spans) - print(losses["coref"]) # < 0.001 # test the trained model doc = nlp(test_text) - print("AFTER", doc.spans) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: nlp.to_disk(tmp_dir) nlp2 = util.load_model_from_path(tmp_dir) doc2 = nlp2(test_text) - print("doc2", doc2.spans) # Make sure that running pipe twice, or comparing to call, always amounts to the same predictions texts = [ @@ -143,12 +148,16 @@ def test_overfitting_IO(nlp): "I noticed many friends around me", "They received it. They received the SMS.", ] - batch_deps_1 = [doc.spans for doc in nlp.pipe(texts)] + docs = list(nlp.pipe(texts)) + batch_deps_1 = [doc.spans for doc in docs] print(batch_deps_1) - batch_deps_2 = [doc.spans for doc in nlp.pipe(texts)] + docs = list(nlp.pipe(texts)) + batch_deps_2 = [doc.spans for doc in docs] print(batch_deps_2) - no_batch_deps = [doc.spans for doc in [nlp(text) for text in texts]] + docs = [nlp(text) for text in texts] + no_batch_deps = [doc.spans for doc in docs] print(no_batch_deps) + print("FINISH") # assert_equal(batch_deps_1, batch_deps_2) # assert_equal(batch_deps_1, no_batch_deps) @@ -183,7 +192,6 @@ def test_tokenization_mismatch(nlp): losses = {} nlp.update(train_examples, sgd=optimizer, losses=losses) doc = nlp(test_text) - print(i, doc.spans) # test the trained model doc = nlp(test_text) @@ -202,12 +210,11 @@ def test_tokenization_mismatch(nlp): ] # save the docs so they don't get garbage collected - docs = list(nlp.pipe(texts)) - batch_deps_1 = [doc.spans for doc in docs] - docs = list(nlp.pipe(texts)) - batch_deps_2 = [doc.spans for doc in docs] - docs = [nlp(text) for text in texts] - no_batch_deps = [doc.spans for doc in docs] + docs1 = list(nlp.pipe(texts)) + docs2 = list(nlp.pipe(texts)) + docs3 = [nlp(text) for text in texts] + assert spans2ints(docs1[0]) == spans2ints(docs2[0]) + assert spans2ints(docs1[0]) == spans2ints(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_crossing_spans(): From 1a782592c4dde437e240fed095ed472c029927de Mon Sep 17 00:00:00 2001 From: kadarakos Date: Tue, 28 Jun 2022 12:53:20 +0000 Subject: [PATCH 153/188] make sure same device --- spacy/ml/models/coref.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index a8c880a3961..660ef68c544 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -2,13 +2,16 @@ from thinc.api import Model, chain from thinc.api import PyTorchWrapper, ArgsKwargs -from thinc.types import Floats2d, Ints2d, Ints1d +from thinc.types import Floats2d from thinc.util import torch, xp2torch, torch2xp from ...tokens import Doc from ...util import registry +EPSILON = 1e-7 + + @registry.architectures("spacy.Coref.v1") def build_wl_coref_model( tok2vec: Model[List[Doc], List[Floats2d]], @@ -42,7 +45,9 @@ def build_wl_coref_model( return coref_model -def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bool): +def convert_coref_clusterer_inputs( + model: Model, X: List[Floats2d], is_train: bool +): # The input here is List[Floats2d], one for each doc # just use the first # TODO real batching @@ -50,7 +55,7 @@ def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bo word_features = xp2torch(X, requires_grad=is_train) # TODO fix or remove type annotations - def backprop(args: ArgsKwargs): #-> List[Floats2d]: + def backprop(args: ArgsKwargs): #-> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) return [gradients] @@ -58,7 +63,9 @@ def backprop(args: ArgsKwargs): #-> List[Floats2d]: return ArgsKwargs(args=(word_features,), kwargs={}), backprop -def convert_coref_clusterer_outputs(model: Model, inputs_outputs, is_train: bool): +def convert_coref_clusterer_outputs( + model: Model, inputs_outputs, is_train: bool +): _, outputs = inputs_outputs scores, indices = outputs @@ -149,10 +156,10 @@ def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tens a_scores_lst: List[torch.Tensor] = [] for i in range(0, len(words), batch_size): - pw_batch = pw[i : i + batch_size] - words_batch = words[i : i + batch_size] - top_indices_batch = top_indices[i : i + batch_size] - top_rough_scores_batch = top_rough_scores[i : i + batch_size] + pw_batch = pw[i:i + batch_size] + words_batch = words[i:i + batch_size] + top_indices_batch = top_indices[i:i + batch_size] + top_rough_scores_batch = top_rough_scores[i:i + batch_size] # a_scores_batch [batch_size, n_ants] a_scores_batch = self.a_scorer( @@ -168,7 +175,6 @@ def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tens return coref_scores, top_indices -EPSILON = 1e-7 # Note this function is kept here to keep a torch dep out of coref_util. def add_dummy(tensor: torch.Tensor, eps: bool = False): """Prepends zeros (or a very small value if eps is True) @@ -294,7 +300,7 @@ def __init__(self, features: int, dropout: float, antecedent_limit: int): self.k = antecedent_limit def forward( - self, # type: ignore # pylint: disable=arguments-differ #35566 in pytorch + self, # type: ignore mentions: torch.Tensor, ) -> Tuple[torch.Tensor, torch.Tensor]: """ @@ -305,6 +311,7 @@ def forward( pair_mask = torch.arange(mentions.shape[0]) pair_mask = pair_mask.unsqueeze(1) - pair_mask.unsqueeze(0) pair_mask = torch.log((pair_mask > 0).to(torch.float)) + pair_mask = pair_mask.to(mentions.device) bilinear_scores = self.dropout(self.bilinear(mentions)).mm(mentions.T) rough_scores = pair_mask + bilinear_scores top_scores, indices = torch.topk( @@ -340,5 +347,6 @@ def forward(self, top_indices: torch.Tensor) -> torch.Tensor: log_distance = distance.to(torch.float).log2().floor_() log_distance = log_distance.clamp_max_(max=6).to(torch.long) distance = torch.where(distance < 5, distance - 1, log_distance + 2) + distance = distance.to(top_indices.device) distance = self.distance_emb(distance) return self.dropout(distance) From 0076f0f617d73e8c1a4415a375c4e0eff9e7103d Mon Sep 17 00:00:00 2001 From: kadarakos Date: Wed, 29 Jun 2022 06:58:47 +0000 Subject: [PATCH 154/188] span predictor device fix --- spacy/ml/models/span_predictor.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 378b79e9be2..d44e632bdc7 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -182,11 +182,12 @@ def forward( torch.Tensor: span start/end scores, (n_heads x n_words x 2) """ # If we don't receive heads, return empty + device = heads_ids.device if heads_ids.nelement() == 0: return torch.empty(size=(0,)) # Obtain distance embedding indices, [n_heads, n_words] relative_positions = heads_ids.unsqueeze(1) - torch.arange( - words.shape[0] + words.shape[0], device=device ).unsqueeze(0) md = self.max_distance # make all valid distances positive @@ -210,20 +211,26 @@ def forward( dim=1, ) lengths = same_sent.sum(dim=1) - padding_mask = torch.arange(0, lengths.max().item()).unsqueeze(0) + padding_mask = torch.arange( + 0, lengths.max().item(), device=device + ).unsqueeze(0) # (n_heads x max_sent_len) padding_mask = padding_mask < lengths.unsqueeze(1) # (n_heads x max_sent_len x input_size * 2 + distance_emb_size) # This is necessary to allow the convolution layer to look at several # word scores - padded_pairs = torch.zeros(*padding_mask.shape, pair_matrix.shape[-1]) + padded_pairs = torch.zeros( + *padding_mask.shape, pair_matrix.shape[-1], device=device + ) padded_pairs[padding_mask] = pair_matrix res = self.ffnn(padded_pairs) # (n_heads x n_candidates x last_layer_output) res = self.conv(res.permute(0, 2, 1)).permute( 0, 2, 1 ) # (n_heads x n_candidates, 2) - scores = torch.full((heads_ids.shape[0], words.shape[0], 2), float("-inf")) + scores = torch.full( + (heads_ids.shape[0], words.shape[0], 2), float("-inf"), device=device + ) scores[rows, cols] = res[padding_mask] # Make sure that start <= head <= end during inference if not self.training: From dd812ca84ab0fc1e9b10920481aff0195bfb6043 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 29 Jun 2022 19:30:37 +0900 Subject: [PATCH 155/188] Handle case with nothing to score in span predictor This case was not handled correctly. It may be desirable to make changes in the coref component to make sure this doesn't happen, but the span predictor should also handle this kind of data intelligently internally. Note that something is still weird because the span predictor seems to not be learning. --- spacy/scorer.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/spacy/scorer.py b/spacy/scorer.py index 14b4b2a7956..aac4c75ed17 100644 --- a/spacy/scorer.py +++ b/spacy/scorer.py @@ -916,6 +916,10 @@ def score_span_predictions(examples: Iterable[Example], **cfg): ref = eg.reference pred = eg.predicted for key, gold_sg in ref.spans.items(): + #TODO it might be better to do something like pred.spans.get(key, []) + if len(gold_sg) == 0: + # if there are no spans there's nothing to predict + continue if key.startswith(output_prefix): pred_sg = pred.spans[key] for gold_mention, pred_mention in zip(gold_sg, pred_sg): @@ -924,6 +928,9 @@ def score_span_predictions(examples: Iterable[Example], **cfg): pred_starts.append(pred_mention.start) pred_ends.append(pred_mention.end) + # it's possible there are no heads to predict from, in which case, skip + if len(starts) == 0: + continue # see how many are perfect cs = [a == b for a, b in zip(starts, pred_starts)] @@ -933,7 +940,13 @@ def score_span_predictions(examples: Iterable[Example], **cfg): scores.append(float(accuracy)) out_key = f"span_{output_prefix}_accuracy" - return {out_key: mean(scores)} + + # it is possible there was nothing to score + final = 0.0 + if len(scores) > 0: + final = mean(scores) + + return {out_key: final} def get_ner_prf(examples: Iterable[Example], **kwargs) -> Dict[str, Any]: From 5192ac16170c51e4a3ed0c8d930a4988853c4dd2 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 14:48:42 +0900 Subject: [PATCH 156/188] Clean tests. --- spacy/tests/pipeline/test_coref.py | 31 +++++++++--------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 584db99b889..73c09b48e74 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -35,7 +35,8 @@ # fmt: on def spans2ints(doc): - """Convert doc.spans to nested list of ints for comparison. + """Convert doc.spans to nested list of ints for comparison. + The ints are token indices. This is useful for checking consistency of predictions. """ @@ -98,21 +99,14 @@ def test_coref_serialization(nlp): assert nlp.pipe_names == ["coref"] text = "She gave me her pen." doc = nlp(text) - spans_result = doc.spans with make_tempdir() as tmp_dir: nlp.to_disk(tmp_dir) nlp2 = spacy.load(tmp_dir) assert nlp2.pipe_names == ["coref"] doc2 = nlp2(text) - spans_result2 = doc2.spans - print(1, [(k, len(v)) for k, v in spans_result.items()]) - print(2, [(k, len(v)) for k, v in spans_result2.items()]) - # Note: spans do not compare equal because docs are different and docs - # use object identity for equality - for k, v in spans_result.items(): - assert str(spans_result[k]) == str(spans_result2[k]) - # assert spans_result == spans_result2 + + assert spans2ints(doc) == spans2ints(doc2) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -148,18 +142,11 @@ def test_overfitting_IO(nlp): "I noticed many friends around me", "They received it. They received the SMS.", ] - docs = list(nlp.pipe(texts)) - batch_deps_1 = [doc.spans for doc in docs] - print(batch_deps_1) - docs = list(nlp.pipe(texts)) - batch_deps_2 = [doc.spans for doc in docs] - print(batch_deps_2) - docs = [nlp(text) for text in texts] - no_batch_deps = [doc.spans for doc in docs] - print(no_batch_deps) - print("FINISH") - # assert_equal(batch_deps_1, batch_deps_2) - # assert_equal(batch_deps_1, no_batch_deps) + docs1 = list(nlp.pipe(texts)) + docs2 = list(nlp.pipe(texts)) + docs3 = [nlp(text) for text in texts] + assert spans2ints(docs1[0]) == spans2ints(docs2[0]) + assert spans2ints(docs1[0]) == spans2ints(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_tokenization_mismatch(nlp): From 1dacecbbfbf1648ec0d6a44d0c53d722de0c2c40 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 14:49:02 +0900 Subject: [PATCH 157/188] Run black --- spacy/tests/pipeline/test_coref.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 73c09b48e74..4b8ca165386 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -34,17 +34,19 @@ ] # fmt: on + def spans2ints(doc): - """Convert doc.spans to nested list of ints for comparison. + """Convert doc.spans to nested list of ints for comparison. The ints are token indices. This is useful for checking consistency of predictions. """ out = [] for key, cluster in doc.spans.items(): - out.append( [(ss.start, ss.end) for ss in cluster] ) + out.append([(ss.start, ss.end) for ss in cluster]) return out + @pytest.fixture def nlp(): return English() @@ -70,6 +72,7 @@ def test_not_initialized(nlp): with pytest.raises(ValueError, match="E109"): nlp(text) + @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized(nlp): nlp.add_pipe("coref") @@ -148,6 +151,7 @@ def test_overfitting_IO(nlp): assert spans2ints(docs1[0]) == spans2ints(docs2[0]) assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_tokenization_mismatch(nlp): train_examples = [] @@ -158,7 +162,7 @@ def test_tokenization_mismatch(nlp): for key, cluster in ref.spans.items(): char_spans[key] = [] for span in cluster: - char_spans[key].append( (span[0].idx, span[-1].idx + len(span[-1])) ) + char_spans[key].append((span[0].idx, span[-1].idx + len(span[-1]))) with ref.retokenize() as retokenizer: # merge "many friends" retokenizer.merge(ref[5:7]) @@ -203,6 +207,7 @@ def test_tokenization_mismatch(nlp): assert spans2ints(docs1[0]) == spans2ints(docs2[0]) assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_crossing_spans(): starts = [6, 10, 0, 1, 0, 1, 0, 1, 2, 2, 2] @@ -215,6 +220,7 @@ def test_crossing_spans(): guess = sorted(guess) assert gold == guess + @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_sentence_map(snlp): doc = snlp("I like text. This is text.") From 201731df2d16d33c74dac248261f0a35808eb32d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 15:12:53 +0900 Subject: [PATCH 158/188] Move spans2ints to util --- spacy/ml/models/coref_util.py | 14 +++++++++++++- spacy/tests/pipeline/test_coref.py | 11 +---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index bd577e65f81..9281ad0c747 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -143,7 +143,7 @@ def create_head_span_idxs(ops, doclen: int): def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: - """Given a Doc, convert the cluster spans to simple int tuple lists. The + """Given a Doc, convert the cluster spans to simple int tuple lists. The ints are char spans, to be tokenization independent. """ out = [] @@ -203,3 +203,15 @@ def create_gold_scores( # caller needs to convert to array, and add placeholder return out + + +def spans2ints(doc): + """Convert doc.spans to nested list of ints for comparison. + The ints are token indices. + + This is useful for checking consistency of predictions. + """ + out = [] + for key, cluster in doc.spans.items(): + out.append([(ss.start, ss.end) for ss in cluster]) + return out diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 4b8ca165386..3bde6ad34ef 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -9,6 +9,7 @@ DEFAULT_CLUSTER_PREFIX, select_non_crossing_spans, get_sentence_ids, + spans2ints, ) from thinc.util import has_torch @@ -35,16 +36,6 @@ # fmt: on -def spans2ints(doc): - """Convert doc.spans to nested list of ints for comparison. - The ints are token indices. - - This is useful for checking consistency of predictions. - """ - out = [] - for key, cluster in doc.spans.items(): - out.append([(ss.start, ss.end) for ss in cluster]) - return out @pytest.fixture From 1a4dbb702d6da012db691f2a346588476f27ca4d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 15:13:15 +0900 Subject: [PATCH 159/188] Add basic span predictor tests --- spacy/tests/pipeline/test_span_predictor.py | 129 ++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 spacy/tests/pipeline/test_span_predictor.py diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py new file mode 100644 index 00000000000..1adaecd3ff3 --- /dev/null +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -0,0 +1,129 @@ +import pytest +import spacy + +from spacy import util +from spacy.training import Example +from spacy.lang.en import English +from spacy.tests.util import make_tempdir +from spacy.ml.models.coref_util import ( + DEFAULT_CLUSTER_PREFIX, + select_non_crossing_spans, + get_sentence_ids, + spans2ints, +) + +from thinc.util import has_torch + +# fmt: off +TRAIN_DATA = [ + ( + "John Smith picked up the red ball and he threw it away.", + { + "spans": { + f"{DEFAULT_CLUSTER_PREFIX}_1": [ + (0, 11, "MENTION"), # John Smith + (38, 41, "MENTION"), # he + + ], + f"{DEFAULT_CLUSTER_PREFIX}_2": [ + (25, 33, "MENTION"), # red ball + (47, 50, "MENTION"), # it + ], + f"coref_head_clusters_1": [ + (5, 11, "MENTION"), # Smith + (38, 41, "MENTION"), # he + + ], + f"coref_head_clusters_2": [ + (29, 33, "MENTION"), # red ball + (47, 50, "MENTION"), # it + ] + } + }, + ), +] +# fmt: on + + +@pytest.fixture +def nlp(): + return English() + + +@pytest.fixture +def snlp(): + en = English() + en.add_pipe("sentencizer") + return en + + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_add_pipe(nlp): + nlp.add_pipe("span_predictor") + assert nlp.pipe_names == ["span_predictor"] + + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_not_initialized(nlp): + nlp.add_pipe("span_predictor") + text = "She gave me her pen." + with pytest.raises(ValueError, match="E109"): + nlp(text) + + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_span_predictor_serialization(nlp): + # Test that the span predictor component can be serialized + nlp.add_pipe("span_predictor", last=True) + nlp.initialize() + assert nlp.pipe_names == ["span_predictor"] + text = "She gave me her pen." + doc = nlp(text) + + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = spacy.load(tmp_dir) + assert nlp2.pipe_names == ["span_predictor"] + doc2 = nlp2(text) + + assert spans2ints(doc) == spans2ints(doc2) + + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_overfitting_IO(nlp): + # Simple test to try and quickly overfit - ensuring the ML models work correctly + train_examples = [] + for text, annot in TRAIN_DATA: + train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) + + nlp.add_pipe("span_predictor") + optimizer = nlp.initialize() + test_text = TRAIN_DATA[0][0] + doc = nlp(test_text) + + # Needs ~12 epochs to converge + for i in range(15): + losses = {} + nlp.update(train_examples, sgd=optimizer, losses=losses) + doc = nlp(test_text) + + # test the trained model + doc = nlp(test_text) + + # Also test the results are still the same after IO + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = util.load_model_from_path(tmp_dir) + doc2 = nlp2(test_text) + + # Make sure that running pipe twice, or comparing to call, always amounts to the same predictions + texts = [ + test_text, + "I noticed many friends around me", + "They received it. They received the SMS.", + ] + docs1 = list(nlp.pipe(texts)) + docs2 = list(nlp.pipe(texts)) + docs3 = [nlp(text) for text in texts] + assert spans2ints(docs1[0]) == spans2ints(docs2[0]) + assert spans2ints(docs1[0]) == spans2ints(docs3[0]) From 619b1102e66c68a8ecb9db31e1959764b29035ab Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 15:32:35 +0900 Subject: [PATCH 160/188] Use config to specify tok2vec_size --- spacy/ml/models/coref.py | 1 - spacy/tests/pipeline/test_coref.py | 11 ++++++----- spacy/tests/pipeline/test_span_predictor.py | 6 ++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 1963a412796..22234390ea4 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -25,7 +25,6 @@ def build_wl_coref_model( tok2vec_size: int = 768, # tok2vec size ): # TODO add model return types - tok2vec_size = 64 with Model.define_operators({">>": chain}): coref_clusterer = PyTorchWrapper( diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 3bde6ad34ef..89906c87bd8 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -36,6 +36,7 @@ # fmt: on +CONFIG = {"model": {"@architectures": "spacy.Coref.v1", "tok2vec_size": 64}} @pytest.fixture @@ -66,7 +67,7 @@ def test_not_initialized(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized(nlp): - nlp.add_pipe("coref") + nlp.add_pipe("coref", config=CONFIG) nlp.initialize() assert nlp.pipe_names == ["coref"] text = "She gave me her pen." @@ -78,7 +79,7 @@ def test_initialized(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized_short(nlp): - nlp.add_pipe("coref") + nlp.add_pipe("coref", config=CONFIG) nlp.initialize() assert nlp.pipe_names == ["coref"] text = "Hi there" @@ -88,7 +89,7 @@ def test_initialized_short(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_coref_serialization(nlp): # Test that the coref component can be serialized - nlp.add_pipe("coref", last=True) + nlp.add_pipe("coref", last=True, config=CONFIG) nlp.initialize() assert nlp.pipe_names == ["coref"] text = "She gave me her pen." @@ -110,7 +111,7 @@ def test_overfitting_IO(nlp): for text, annot in TRAIN_DATA: train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) - nlp.add_pipe("coref") + nlp.add_pipe("coref", config=CONFIG) optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) @@ -165,7 +166,7 @@ def test_tokenization_mismatch(nlp): train_examples.append(eg) - nlp.add_pipe("coref") + nlp.add_pipe("coref", config=CONFIG) optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 1adaecd3ff3..7d7a7527986 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -44,6 +44,8 @@ ] # fmt: on +CONFIG = {"model": {"@architectures": "spacy.SpanPredictor.v1", "tok2vec_size": 64}} + @pytest.fixture def nlp(): @@ -74,7 +76,7 @@ def test_not_initialized(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_span_predictor_serialization(nlp): # Test that the span predictor component can be serialized - nlp.add_pipe("span_predictor", last=True) + nlp.add_pipe("span_predictor", last=True, config=CONFIG) nlp.initialize() assert nlp.pipe_names == ["span_predictor"] text = "She gave me her pen." @@ -96,7 +98,7 @@ def test_overfitting_IO(nlp): for text, annot in TRAIN_DATA: train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) - nlp.add_pipe("span_predictor") + nlp.add_pipe("span_predictor", config=CONFIG) optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) From a46bc03abb0857ee8cd11bc83257e3c70aeed705 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 16:01:27 +0900 Subject: [PATCH 161/188] Add failing test with tokenization mismatch This test only fails due to the explicity assert False at the moment, but the debug output shows that the learned spans are all off by one due to misalignment. So the code still needs fixing. --- spacy/tests/pipeline/test_span_predictor.py | 84 +++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 7d7a7527986..9281df354bf 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -21,22 +21,22 @@ { "spans": { f"{DEFAULT_CLUSTER_PREFIX}_1": [ - (0, 11, "MENTION"), # John Smith - (38, 41, "MENTION"), # he + (0, 10, "MENTION"), # John Smith + (38, 40, "MENTION"), # he ], f"{DEFAULT_CLUSTER_PREFIX}_2": [ (25, 33, "MENTION"), # red ball - (47, 50, "MENTION"), # it + (47, 49, "MENTION"), # it ], f"coref_head_clusters_1": [ - (5, 11, "MENTION"), # Smith - (38, 41, "MENTION"), # he + (5, 10, "MENTION"), # Smith + (38, 40, "MENTION"), # he ], f"coref_head_clusters_2": [ (29, 33, "MENTION"), # red ball - (47, 50, "MENTION"), # it + (47, 49, "MENTION"), # it ] } }, @@ -129,3 +129,75 @@ def test_overfitting_IO(nlp): docs3 = [nlp(text) for text in texts] assert spans2ints(docs1[0]) == spans2ints(docs2[0]) assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_tokenization_mismatch(nlp): + train_examples = [] + for text, annot in TRAIN_DATA: + eg = Example.from_dict(nlp.make_doc(text), annot) + ref = eg.reference + char_spans = {} + for key, cluster in ref.spans.items(): + char_spans[key] = [] + for span in cluster: + char_spans[key].append((span[0].idx, span[-1].idx + len(span[-1]))) + with ref.retokenize() as retokenizer: + # merge "picked up" + retokenizer.merge(ref[2:4]) + + # Note this works because it's the same doc and we know the keys + for key, _ in ref.spans.items(): + spans = char_spans[key] + ref.spans[key] = [ref.char_span(*span) for span in spans] + + # Finally, copy over the head spans to the pred + pred = eg.predicted + for key, val in ref.spans.items(): + if key.startswith("coref_head_clusters"): + spans = char_spans[key] + pred.spans[key] = [pred.char_span(*span) for span in spans] + + train_examples.append(eg) + + nlp.add_pipe("span_predictor", config=CONFIG) + optimizer = nlp.initialize() + test_text = TRAIN_DATA[0][0] + doc = nlp(test_text) + + for i in range(100): + losses = {} + nlp.update(train_examples, sgd=optimizer, losses=losses) + doc = nlp(test_text) + + # test the trained model; need to use doc with head spans on it already + test_doc = train_examples[0].predicted + doc = nlp(test_doc) + + # XXX DEBUG + print("SPANS", len(doc.spans)) + for key, val in doc.spans.items(): + print(key, val) + print("...") + + # Also test the results are still the same after IO + with make_tempdir() as tmp_dir: + nlp.to_disk(tmp_dir) + nlp2 = util.load_model_from_path(tmp_dir) + doc2 = nlp2(test_text) + + # Make sure that running pipe twice, or comparing to call, always amounts to the same predictions + texts = [ + test_text, + "I noticed many friends around me", + "They received it. They received the SMS.", + ] + + # save the docs so they don't get garbage collected + docs1 = list(nlp.pipe(texts)) + docs2 = list(nlp.pipe(texts)) + docs3 = [nlp(text) for text in texts] + assert spans2ints(docs1[0]) == spans2ints(docs2[0]) + assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + assert False + From fd574a89c4ab45dd2b317bc2348a958712efe531 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 19:34:15 +0900 Subject: [PATCH 162/188] Update overfitting test --- spacy/tests/pipeline/test_span_predictor.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 9281df354bf..4434b665165 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -98,19 +98,29 @@ def test_overfitting_IO(nlp): for text, annot in TRAIN_DATA: train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) + train_examples = [] + for text, annot in TRAIN_DATA: + eg = Example.from_dict(nlp.make_doc(text), annot) + ref = eg.reference + # Finally, copy over the head spans to the pred + pred = eg.predicted + for key, spans in ref.spans.items(): + if key.startswith("coref_head_clusters"): + pred.spans[key] = [pred[span.start:span.end] for span in spans] + + train_examples.append(eg) nlp.add_pipe("span_predictor", config=CONFIG) optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) - # Needs ~12 epochs to converge - for i in range(15): + for i in range(1500): losses = {} nlp.update(train_examples, sgd=optimizer, losses=losses) doc = nlp(test_text) - # test the trained model - doc = nlp(test_text) + # test the trained model, using the pred since it has heads + doc = nlp(train_examples[0].predicted) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: From cf33b48fe06a4469be6079f9712deae04c254137 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 20:10:53 +0900 Subject: [PATCH 163/188] Update tests --- spacy/ml/models/coref_util.py | 8 +++++--- spacy/tests/pipeline/test_span_predictor.py | 16 +++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 9281ad0c747..00d501f801b 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -207,11 +207,13 @@ def create_gold_scores( def spans2ints(doc): """Convert doc.spans to nested list of ints for comparison. - The ints are token indices. + The ints are character indices, and the spans groups are sorted by key first. This is useful for checking consistency of predictions. """ out = [] - for key, cluster in doc.spans.items(): - out.append([(ss.start, ss.end) for ss in cluster]) + keys = sorted([key for key in doc.spans]) + for key in keys: + cluster = doc.spans[key] + out.append([(ss.start_char, ss.end_char) for ss in cluster]) return out diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 4434b665165..3d88b9548db 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -114,13 +114,15 @@ def test_overfitting_IO(nlp): test_text = TRAIN_DATA[0][0] doc = nlp(test_text) - for i in range(1500): + for i in range(15): losses = {} nlp.update(train_examples, sgd=optimizer, losses=losses) doc = nlp(test_text) # test the trained model, using the pred since it has heads doc = nlp(train_examples[0].predicted) + # XXX This actually tests that it can overfit + assert spans2ints(doc) == spans2ints(train_examples[0].reference) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: @@ -134,6 +136,7 @@ def test_overfitting_IO(nlp): "I noticed many friends around me", "They received it. They received the SMS.", ] + # XXX Note these have no predictions because they have no input spans docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] @@ -175,7 +178,7 @@ def test_tokenization_mismatch(nlp): test_text = TRAIN_DATA[0][0] doc = nlp(test_text) - for i in range(100): + for i in range(15): losses = {} nlp.update(train_examples, sgd=optimizer, losses=losses) doc = nlp(test_text) @@ -183,12 +186,8 @@ def test_tokenization_mismatch(nlp): # test the trained model; need to use doc with head spans on it already test_doc = train_examples[0].predicted doc = nlp(test_doc) - - # XXX DEBUG - print("SPANS", len(doc.spans)) - for key, val in doc.spans.items(): - print(key, val) - print("...") + # XXX This actually tests that it can overfit + assert spans2ints(doc) == spans2ints(train_examples[0].reference) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: @@ -209,5 +208,4 @@ def test_tokenization_mismatch(nlp): docs3 = [nlp(text) for text in texts] assert spans2ints(docs1[0]) == spans2ints(docs2[0]) assert spans2ints(docs1[0]) == spans2ints(docs3[0]) - assert False From b09bbc7f5eb8f04e2441f43f063492d3e2fc1d22 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Sun, 3 Jul 2022 20:11:03 +0900 Subject: [PATCH 164/188] Fix alignment issues I believe this resolves issues with tokenization mismatches. --- spacy/pipeline/span_predictor.py | 33 ++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index d7e96a4b294..c9343a97e62 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -231,16 +231,29 @@ def get_loss( for eg in examples: starts = [] ends = [] + keeps = [] + sidx = 0 for key, sg in eg.reference.spans.items(): if key.startswith(self.output_prefix): - for mention in sg: - starts.append(mention.start) - ends.append(mention.end) + for ii, mention in enumerate(sg): + sidx += 1 + # convert to span in pred + sch, ech = (mention.start_char, mention.end_char) + span = eg.predicted.char_span(sch, ech) + # TODO add to errors.py + if span is None: + warnings.warn("Could not align gold span in span predictor, skipping") + continue + starts.append(span.start) + ends.append(span.end) + keeps.append(sidx - 1) starts = self.model.ops.xp.asarray(starts) ends = self.model.ops.xp.asarray(ends) - start_scores = span_scores[:, :, 0] - end_scores = span_scores[:, :, 1] + start_scores = span_scores[:, :, 0][keeps] + end_scores = span_scores[:, :, 1][keeps] + + n_classes = start_scores.shape[1] start_probs = ops.softmax(start_scores, axis=1) end_probs = ops.softmax(end_scores, axis=1) @@ -248,7 +261,14 @@ def get_loss( end_targets = to_categorical(ends, n_classes) start_grads = start_probs - start_targets end_grads = end_probs - end_targets - grads = ops.xp.stack((start_grads, end_grads), axis=2) + # now return to original shape, with 0s + final_start_grads = ops.alloc2f(*span_scores[:, :, 0].shape) + final_start_grads[keeps] = start_grads + final_end_grads = ops.alloc2f(*final_start_grads.shape) + final_end_grads[keeps] = end_grads + # XXX Note this only works with fake batching + grads = ops.xp.stack((final_start_grads, final_end_grads), axis=2) + loss = float((grads**2).sum()) return loss, grads @@ -267,6 +287,7 @@ def initialize( if not ex.predicted.spans: # set placeholder for shape inference doc = ex.predicted + # TODO should be able to check if there are some valid docs in the batch assert len(doc) > 2, "Coreference requires at least two tokens" doc.spans[f"{self.input_prefix}_0"] = [doc[0:1], doc[1:2]] X.append(ex.predicted) From c7f333d5938e665b93ac175e89de4f2050284780 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 4 Jul 2022 19:28:35 +0900 Subject: [PATCH 165/188] Rename spans2ints > _spans_to_offsets --- spacy/ml/models/coref_util.py | 2 +- spacy/tests/pipeline/test_coref.py | 12 ++++++------ spacy/tests/pipeline/test_span_predictor.py | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 00d501f801b..772306dec63 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -205,7 +205,7 @@ def create_gold_scores( return out -def spans2ints(doc): +def _spans_to_offsets(doc): """Convert doc.spans to nested list of ints for comparison. The ints are character indices, and the spans groups are sorted by key first. diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 89906c87bd8..9a969acdd39 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -9,7 +9,7 @@ DEFAULT_CLUSTER_PREFIX, select_non_crossing_spans, get_sentence_ids, - spans2ints, + _spans_to_offsets, ) from thinc.util import has_torch @@ -101,7 +101,7 @@ def test_coref_serialization(nlp): assert nlp2.pipe_names == ["coref"] doc2 = nlp2(text) - assert spans2ints(doc) == spans2ints(doc2) + assert _spans_to_offsets(doc) == _spans_to_offsets(doc2) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -140,8 +140,8 @@ def test_overfitting_IO(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert spans2ints(docs1[0]) == spans2ints(docs2[0]) - assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -196,8 +196,8 @@ def test_tokenization_mismatch(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert spans2ints(docs1[0]) == spans2ints(docs2[0]) - assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 3d88b9548db..3a3111bd45b 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -9,7 +9,7 @@ DEFAULT_CLUSTER_PREFIX, select_non_crossing_spans, get_sentence_ids, - spans2ints, + _spans_to_offsets, ) from thinc.util import has_torch @@ -88,7 +88,7 @@ def test_span_predictor_serialization(nlp): assert nlp2.pipe_names == ["span_predictor"] doc2 = nlp2(text) - assert spans2ints(doc) == spans2ints(doc2) + assert _spans_to_offsets(doc) == _spans_to_offsets(doc2) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -122,7 +122,7 @@ def test_overfitting_IO(nlp): # test the trained model, using the pred since it has heads doc = nlp(train_examples[0].predicted) # XXX This actually tests that it can overfit - assert spans2ints(doc) == spans2ints(train_examples[0].reference) + assert _spans_to_offsets(doc) == _spans_to_offsets(train_examples[0].reference) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: @@ -140,8 +140,8 @@ def test_overfitting_IO(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert spans2ints(docs1[0]) == spans2ints(docs2[0]) - assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -187,7 +187,7 @@ def test_tokenization_mismatch(nlp): test_doc = train_examples[0].predicted doc = nlp(test_doc) # XXX This actually tests that it can overfit - assert spans2ints(doc) == spans2ints(train_examples[0].reference) + assert _spans_to_offsets(doc) == _spans_to_offsets(train_examples[0].reference) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: @@ -206,6 +206,6 @@ def test_tokenization_mismatch(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert spans2ints(docs1[0]) == spans2ints(docs2[0]) - assert spans2ints(docs1[0]) == spans2ints(docs3[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) + assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) From 178feae00ab71a27657250bff95b53aba1a9a4e8 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 4 Jul 2022 19:37:42 +0900 Subject: [PATCH 166/188] Add tests to give up with whitespace differences Docs in Examples are allowed to have arbitrarily different whitespace. Handling that properly would be nice but isn't required, but for now check for it and blow up. --- spacy/pipeline/coref.py | 9 ++++++++- spacy/pipeline/span_predictor.py | 7 +++++++ spacy/tests/pipeline/test_coref.py | 17 +++++++++++++++++ spacy/tests/pipeline/test_span_predictor.py | 18 +++++++++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 1e11a041796..af40d9b06bb 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -218,6 +218,13 @@ def update( total_loss = 0 for eg in examples: + if eg.x.text != eg.y.text: + # TODO assign error number + raise ValueError( + """Text, including whitespace, must match between reference and + predicted docs in coref training. + """ + ) # TODO check this causes no issues (in practice it runs) preds, backprop = self.model.begin_update([eg.predicted]) score_matrix, mention_idx = preds @@ -277,7 +284,7 @@ def get_loss( if span is None: # TODO log more details raise IndexError(Errors.E1043) - cc.append( (span.start, span.end) ) + cc.append((span.start, span.end)) clusters.append(cc) span_idxs = create_head_span_idxs(ops, len(example.predicted)) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index c9343a97e62..aee11ba8e59 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -178,6 +178,13 @@ def update( total_loss = 0 for eg in examples: + if eg.x.text != eg.y.text: + # TODO assign error number + raise ValueError( + """Text, including whitespace, must match between reference and + predicted docs in span predictor training. + """ + ) span_scores, backprop = self.model.begin_update([eg.predicted]) # FIXME, this only happens once in the first 1000 docs of OntoNotes # and I'm not sure yet why. diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 9a969acdd39..7fc4864a3c4 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -218,3 +218,20 @@ def test_sentence_map(snlp): doc = snlp("I like text. This is text.") sm = get_sentence_ids(doc) assert sm == [0, 0, 0, 0, 1, 1, 1, 1] + + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_whitespace_mismatch(nlp): + train_examples = [] + for text, annot in TRAIN_DATA: + eg = Example.from_dict(nlp.make_doc(text), annot) + eg.predicted = nlp.make_doc(" " + text) + train_examples.append(eg) + + nlp.add_pipe("coref", config=CONFIG) + optimizer = nlp.initialize() + test_text = TRAIN_DATA[0][0] + doc = nlp(test_text) + + with pytest.raises(ValueError, match="whitespace"): + nlp.update(train_examples, sgd=optimizer) diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 3a3111bd45b..a79756d88d7 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -106,7 +106,7 @@ def test_overfitting_IO(nlp): pred = eg.predicted for key, spans in ref.spans.items(): if key.startswith("coref_head_clusters"): - pred.spans[key] = [pred[span.start:span.end] for span in spans] + pred.spans[key] = [pred[span.start : span.end] for span in spans] train_examples.append(eg) nlp.add_pipe("span_predictor", config=CONFIG) @@ -209,3 +209,19 @@ def test_tokenization_mismatch(nlp): assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) + +@pytest.mark.skipif(not has_torch, reason="Torch not available") +def test_whitespace_mismatch(nlp): + train_examples = [] + for text, annot in TRAIN_DATA: + eg = Example.from_dict(nlp.make_doc(text), annot) + eg.predicted = nlp.make_doc(" " + text) + train_examples.append(eg) + + nlp.add_pipe("span_predictor", config=CONFIG) + optimizer = nlp.initialize() + test_text = TRAIN_DATA[0][0] + doc = nlp(test_text) + + with pytest.raises(ValueError, match="whitespace"): + nlp.update(train_examples, sgd=optimizer) From 63e27b5e443626038782e35b1928c964ca806c00 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 13:46:02 +0900 Subject: [PATCH 167/188] Update spacy/ml/models/coref_util.py Co-authored-by: kadarakos --- spacy/ml/models/coref_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 772306dec63..38af629d5df 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -143,7 +143,7 @@ def create_head_span_idxs(ops, doclen: int): def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: - """Given a Doc, convert the cluster spans to simple int tuple lists. The + """Convert the span clusters in a Doc to simple integer tuple lists. The ints are char spans, to be tokenization independent. """ out = [] From 8f598d7b01745c86069da5a409787c933ef39883 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 14:03:09 +0900 Subject: [PATCH 168/188] Feedback from code review --- spacy/ml/models/coref_util.py | 2 +- spacy/tests/pipeline/test_span_predictor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 38af629d5df..3be0bd835f2 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -205,7 +205,7 @@ def create_gold_scores( return out -def _spans_to_offsets(doc): +def _spans_to_offsets(doc: Doc) -> List[List[Tuple[int, int]]]: """Convert doc.spans to nested list of ints for comparison. The ints are character indices, and the spans groups are sorted by key first. diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index a79756d88d7..c0e59e914c9 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -154,7 +154,7 @@ def test_tokenization_mismatch(nlp): for key, cluster in ref.spans.items(): char_spans[key] = [] for span in cluster: - char_spans[key].append((span[0].idx, span[-1].idx + len(span[-1]))) + char_spans[key].append((span.start_char, span.end_char)) with ref.retokenize() as retokenizer: # merge "picked up" retokenizer.merge(ref[2:4]) From 6f5cf838ecb45e5d8ea85aa99a199525f30df1c5 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 14:05:05 +0900 Subject: [PATCH 169/188] Remove _spans_to_offsets Basically the same as get_clusters_from_doc --- spacy/ml/models/coref_util.py | 14 -------------- spacy/tests/pipeline/test_coref.py | 12 ++++++------ spacy/tests/pipeline/test_span_predictor.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 3be0bd835f2..1a6bc636499 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -203,17 +203,3 @@ def create_gold_scores( # caller needs to convert to array, and add placeholder return out - - -def _spans_to_offsets(doc: Doc) -> List[List[Tuple[int, int]]]: - """Convert doc.spans to nested list of ints for comparison. - The ints are character indices, and the spans groups are sorted by key first. - - This is useful for checking consistency of predictions. - """ - out = [] - keys = sorted([key for key in doc.spans]) - for key in keys: - cluster = doc.spans[key] - out.append([(ss.start_char, ss.end_char) for ss in cluster]) - return out diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 7fc4864a3c4..3e297ddcdf1 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -9,7 +9,7 @@ DEFAULT_CLUSTER_PREFIX, select_non_crossing_spans, get_sentence_ids, - _spans_to_offsets, + get_clusters_from_doc, ) from thinc.util import has_torch @@ -101,7 +101,7 @@ def test_coref_serialization(nlp): assert nlp2.pipe_names == ["coref"] doc2 = nlp2(text) - assert _spans_to_offsets(doc) == _spans_to_offsets(doc2) + assert get_clusters_from_doc(doc) == get_clusters_from_doc(doc2) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -140,8 +140,8 @@ def test_overfitting_IO(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs2[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -196,8 +196,8 @@ def test_tokenization_mismatch(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs2[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index c0e59e914c9..8a6c62011ad 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -9,7 +9,7 @@ DEFAULT_CLUSTER_PREFIX, select_non_crossing_spans, get_sentence_ids, - _spans_to_offsets, + get_clusters_from_doc, ) from thinc.util import has_torch @@ -88,7 +88,7 @@ def test_span_predictor_serialization(nlp): assert nlp2.pipe_names == ["span_predictor"] doc2 = nlp2(text) - assert _spans_to_offsets(doc) == _spans_to_offsets(doc2) + assert get_clusters_from_doc(doc) == get_clusters_from_doc(doc2) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -122,7 +122,7 @@ def test_overfitting_IO(nlp): # test the trained model, using the pred since it has heads doc = nlp(train_examples[0].predicted) # XXX This actually tests that it can overfit - assert _spans_to_offsets(doc) == _spans_to_offsets(train_examples[0].reference) + assert get_clusters_from_doc(doc) == get_clusters_from_doc(train_examples[0].reference) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: @@ -140,8 +140,8 @@ def test_overfitting_IO(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs2[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") @@ -187,7 +187,7 @@ def test_tokenization_mismatch(nlp): test_doc = train_examples[0].predicted doc = nlp(test_doc) # XXX This actually tests that it can overfit - assert _spans_to_offsets(doc) == _spans_to_offsets(train_examples[0].reference) + assert get_clusters_from_doc(doc) == get_clusters_from_doc(train_examples[0].reference) # Also test the results are still the same after IO with make_tempdir() as tmp_dir: @@ -206,8 +206,8 @@ def test_tokenization_mismatch(nlp): docs1 = list(nlp.pipe(texts)) docs2 = list(nlp.pipe(texts)) docs3 = [nlp(text) for text in texts] - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs2[0]) - assert _spans_to_offsets(docs1[0]) == _spans_to_offsets(docs3[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs2[0]) + assert get_clusters_from_doc(docs1[0]) == get_clusters_from_doc(docs3[0]) @pytest.mark.skipif(not has_torch, reason="Torch not available") From da9c379355c9530592cf53ad576efdfc1fd24ac1 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 17:13:31 +0900 Subject: [PATCH 170/188] Update docs Parameter names in architecture docs were not updated after parameters were renamed. --- spacy/pipeline/span_predictor.py | 2 +- website/docs/api/architectures.md | 39 +++++++++++++++++-------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index d7e96a4b294..99a1f7ef64b 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -29,7 +29,7 @@ conv_channels = 4 window_size = 1 max_distance = 128 -prefix = coref_head_clusters +prefix = "coref_head_clusters" [model.tok2vec] @architectures = "spacy.Tok2Vec.v2" diff --git a/website/docs/api/architectures.md b/website/docs/api/architectures.md index 4e70eee87cb..e881864a95c 100644 --- a/website/docs/api/architectures.md +++ b/website/docs/api/architectures.md @@ -587,8 +587,8 @@ consists of either two or three subnetworks: run once for each batch. - **lower**: Construct a feature-specific vector for each `(token, feature)` pair. This is also run once for each batch. Constructing the state - representation is then a matter of summing the component features and - applying the non-linearity. + representation is then a matter of summing the component features and applying + the non-linearity. - **upper** (optional): A feed-forward network that predicts scores from the state representation. If not present, the output from the lower model is used as action scores directly. @@ -628,8 +628,8 @@ same signature, but the `use_upper` argument was `True` by default. > ``` Build a tagger model, using a provided token-to-vector component. The tagger -model adds a linear layer with softmax activation to predict scores given -the token vectors. +model adds a linear layer with softmax activation to predict scores given the +token vectors. | Name | Description | | ----------- | ------------------------------------------------------------------------------------------ | @@ -920,8 +920,8 @@ A function that reads an existing `KnowledgeBase` from file. A function that takes as input a [`KnowledgeBase`](/api/kb) and a [`Span`](/api/span) object denoting a named entity, and returns a list of plausible [`Candidate`](/api/kb/#candidate) objects. The default -`CandidateGenerator` uses the text of a mention to find its potential -aliases in the `KnowledgeBase`. Note that this function is case-dependent. +`CandidateGenerator` uses the text of a mention to find its potential aliases in +the `KnowledgeBase`. Note that this function is case-dependent. ## Coreference Architectures @@ -975,7 +975,11 @@ The `Coref` model architecture is a Thinc `Model`. > [model] > @architectures = "spacy.SpanPredictor.v1" > hidden_size = 1024 -> dist_emb_size = 64 +> distance_embedding_size = 64 +> conv_channels = 4 +> window_size = 1 +> max_distance = 128 +> prefix = "coref_head_clusters" > > [model.tok2vec] > @architectures = "spacy-transformers.TransformerListener.v1" @@ -986,13 +990,14 @@ The `Coref` model architecture is a Thinc `Model`. The `SpanPredictor` model architecture is a Thinc `Model`. -| Name | Description | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tok2vec` | The [`tok2vec`](#tok2vec) layer of the model. ~~Model~~ | -| `distance_embedding_size` | A representation of the distance between two candidates. ~~int~~ | -| `dropout` | The dropout to use internally. Unlike some Thinc models, this has separate dropout for the internal PyTorch layers. ~~float~~ | -| `hidden_size` | Size of the main internal layers. ~~int~~ | -| `depth` | Depth of the internal network. ~~int~~ | -| `antecedent_limit` | How many candidate antecedents to keep after rough scoring. This has a significant effect on memory usage. Typical values would be 50 to 200, or higher for very long documents. ~~int~~ | -| `antecedent_batch_size` | Internal batch size. ~~int~~ | -| **CREATES** | The model using the architecture. ~~Model[List[Doc], TupleFloats2d]~~ | +| Name | Description | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `tok2vec` | The [`tok2vec`](#tok2vec) layer of the model. ~~Model~~ | +| `distance_embedding_size` | A representation of the distance between two candidates. ~~int~~ | +| `dropout` | The dropout to use internally. Unlike some Thinc models, this has separate dropout for the internal PyTorch layers. ~~float~~ | +| `hidden_size` | Size of the main internal layers. ~~int~~ | +| `conv_channels` | The number of channels in the internal CNN. ~~int~~ | +| `window_size` | The number of neighboring tokens to consider in the internal CNN. `1` means consider one token on each side. ~~int~~ | +| `max_distance` | The longest possible length of a predicted span. ~~int~~ | +| `prefix` | The prefix that indicates spans to use for input data. ~~string~~ | +| **CREATES** | The model using the architecture. ~~Model[List[Doc], TupleFloats2d]~~ | From c4de3e51a2631ce7b7ac22426c57cf1ff9c50440 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 17:23:41 +0900 Subject: [PATCH 171/188] Remove old TODOs --- spacy/ml/models/span_predictor.py | 4 ---- spacy/pipeline/coref.py | 24 ++++++++++++------------ spacy/pipeline/entity_linker.py | 2 +- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index d44e632bdc7..f11ecb5d55b 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -35,7 +35,6 @@ def build_span_predictor( ), convert_inputs=convert_span_predictor_inputs, ) - # TODO use proper parameter for prefix head_info = build_get_head_metadata(prefix) model = (tok2vec & head_info) >> span_predictor @@ -96,7 +95,6 @@ def predict_span_clusters( def build_get_head_metadata(prefix): - # TODO this name is awful, fix it model = Model( "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward ) @@ -142,7 +140,6 @@ def __init__( raise ValueError("max_distance has to be an even number") # input size = single token size # 64 = probably distance emb size - # TODO check that dist_emb_size use is correct self.ffnn = torch.nn.Sequential( torch.nn.Linear(input_size * 2 + dist_emb_size, hidden_size), torch.nn.ReLU(), @@ -159,7 +156,6 @@ def __init__( torch.nn.Conv1d(dist_emb_size, conv_channels, kernel_size, 1, 1), torch.nn.Conv1d(conv_channels, 2, kernel_size, 1, 1), ) - # TODO make embeddings size a parameter self.max_distance = max_distance # handle distances between +-(max_distance - 2 / 2) self.emb = torch.nn.Embedding(max_distance, dist_emb_size) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index cd07f80e8f1..9fd3c9472d5 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -95,7 +95,7 @@ def make_coref( class CoreferenceResolver(TrainablePipe): """Pipeline component for coreference resolution. - DOCS: https://spacy.io/api/coref (TODO) + DOCS: https://spacy.io/api/coref """ def __init__( @@ -118,8 +118,10 @@ def __init__( are stored in. span_cluster_prefix (str): Prefix for the key in doc.spans to store the coref clusters in. + scorer (Optional[Callable]): The scoring method. Defaults to + Scorer.score_coref_clusters. - DOCS: https://spacy.io/api/coref#init (TODO) + DOCS: https://spacy.io/api/coref#init """ self.vocab = vocab self.model = model @@ -133,11 +135,12 @@ def __init__( def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: """Apply the pipeline's model to a batch of docs, without modifying them. + Return the list of predicted clusters. docs (Iterable[Doc]): The documents to predict. - RETURNS: The models prediction for each document. + RETURNS (List[MentionClusters]): The model's prediction for each document. - DOCS: https://spacy.io/api/coref#predict (TODO) + DOCS: https://spacy.io/api/coref#predict """ out = [] for doc in docs: @@ -163,7 +166,7 @@ def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: docs (Iterable[Doc]): The documents to modify. clusters: The span clusters, produced by CoreferenceResolver.predict. - DOCS: https://spacy.io/api/coref#set_annotations (TODO) + DOCS: https://spacy.io/api/coref#set_annotations """ docs = list(docs) if len(docs) != len(clusters_by_doc): @@ -204,7 +207,7 @@ def update( Updated using the component name as the key. RETURNS (Dict[str, float]): The updated losses dictionary. - DOCS: https://spacy.io/api/coref#update (TODO) + DOCS: https://spacy.io/api/coref#update """ if losses is None: losses = {} @@ -218,12 +221,10 @@ def update( total_loss = 0 for eg in examples: - # TODO check this causes no issues (in practice it runs) preds, backprop = self.model.begin_update([eg.predicted]) score_matrix, mention_idx = preds loss, d_scores = self.get_loss([eg], score_matrix, mention_idx) total_loss += loss - # TODO check shape here backprop((d_scores, mention_idx)) if sgd is not None: @@ -257,7 +258,7 @@ def get_loss( scores: Scores representing the model's predictions. RETURNS (Tuple[float, float]): The loss and the gradient. - DOCS: https://spacy.io/api/coref#get_loss (TODO) + DOCS: https://spacy.io/api/coref#get_loss """ ops = self.model.ops xp = ops.xp @@ -270,9 +271,8 @@ def get_loss( clusters = get_clusters_from_doc(example.reference) span_idxs = create_head_span_idxs(ops, len(example.predicted)) gscores = create_gold_scores(span_idxs, clusters) - # TODO fix type here. This is bools but asarray2f wants ints. + # Note on type here. This is bools but asarray2f wants ints. gscores = ops.asarray2f(gscores) # type: ignore - # top_gscores = xp.take_along_axis(gscores, cidx, axis=1) top_gscores = xp.take_along_axis(gscores, mention_idx, axis=1) # now add the placeholder gold_placeholder = ~top_gscores.any(axis=1).T @@ -304,7 +304,7 @@ def initialize( returns a representative sample of gold-standard Example objects. nlp (Language): The current nlp object the component is part of. - DOCS: https://spacy.io/api/coref#initialize (TODO) + DOCS: https://spacy.io/api/coref#initialize """ validate_get_examples(get_examples, "CoreferenceResolver.initialize") diff --git a/spacy/pipeline/entity_linker.py b/spacy/pipeline/entity_linker.py index aa7985a9c52..36a291a888b 100644 --- a/spacy/pipeline/entity_linker.py +++ b/spacy/pipeline/entity_linker.py @@ -383,7 +383,7 @@ def predict(self, docs: Iterable[Doc]) -> List[str]: no prediction. docs (Iterable[Doc]): The documents to predict. - RETURNS (List[str]): The models prediction for each document. + RETURNS (List[str]): The model's prediction for each document. DOCS: https://spacy.io/api/entitylinker#predict """ From 5e405738d2be0269fa6b636c0e2acbd889329474 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 17:28:05 +0900 Subject: [PATCH 172/188] Update span predictor docstrings --- spacy/pipeline/span_predictor.py | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index 99a1f7ef64b..beec674730c 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -95,6 +95,8 @@ class SpanPredictor(TrainablePipe): """Pipeline component to resolve one-token spans to full spans. Used in coreference resolution. + + DOCS: https://spacy.io/api/span_predictor """ def __init__( @@ -119,6 +121,14 @@ def __init__( } def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: + """Apply the pipeline's model to a batch of docs, without modifying them. + Return the list of predicted span clusters. + + docs (Iterable[Doc]): The documents to predict. + RETURNS (List[MentionClusters]): The model's prediction for each document. + + DOCS: https://spacy.io/api/span_predictor#predict + """ # for now pretend there's just one doc out = [] @@ -151,6 +161,13 @@ def predict(self, docs: Iterable[Doc]) -> List[MentionClusters]: return out def set_annotations(self, docs: Iterable[Doc], clusters_by_doc) -> None: + """Modify a batch of Doc objects, using pre-computed scores. + + docs (Iterable[Doc]): The documents to modify. + clusters: The span clusters, produced by SpanPredictor.predict. + + DOCS: https://spacy.io/api/span_predictor#set_annotations + """ for doc, clusters in zip(docs, clusters_by_doc): for ii, cluster in enumerate(clusters): spans = [doc[mm[0] : mm[1]] for mm in cluster] @@ -166,6 +183,15 @@ def update( ) -> Dict[str, float]: """Learn from a batch of documents and gold-standard information, updating the pipe's model. Delegates to predict and get_loss. + + examples (Iterable[Example]): A batch of Example objects. + drop (float): The dropout rate. + sgd (thinc.api.Optimizer): The optimizer. + losses (Dict[str, float]): Optional record of the loss during training. + Updated using the component name as the key. + RETURNS (Dict[str, float]): The updated losses dictionary. + + DOCS: https://spacy.io/api/span_predictor#update """ if losses is None: losses = {} @@ -222,6 +248,15 @@ def get_loss( examples: Iterable[Example], span_scores: Floats3d, ): + """Find the loss and gradient of loss for the batch of documents and + their predicted scores. + + examples (Iterable[Examples]): The batch of examples. + scores: Scores representing the model's predictions. + RETURNS (Tuple[float, float]): The loss and the gradient. + + DOCS: https://spacy.io/api/span_predictor#get_loss + """ ops = self.model.ops # NOTE This is doing fake batching, and should always get a list of one example @@ -258,6 +293,15 @@ def initialize( *, nlp: Optional[Language] = None, ) -> None: + """Initialize the pipe for training, using a representative set + of data examples. + + get_examples (Callable[[], Iterable[Example]]): Function that + returns a representative sample of gold-standard Example objects. + nlp (Language): The current nlp object the component is part of. + + DOCS: https://spacy.io/api/span_predictor#initialize + """ validate_get_examples(get_examples, "SpanPredictor.initialize") X = [] From ce491364585d80fb057a043481fbe58e5d8dd1b8 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 17:28:15 +0900 Subject: [PATCH 173/188] Update NotImplementedError for coref component --- spacy/pipeline/coref.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 9fd3c9472d5..ce7afb6d481 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -233,7 +233,12 @@ def update( return losses def rehearse(self, examples, *, sgd=None, losses=None, **config): - raise NotImplementedError + # TODO this should be added later + raise NotImplementedError( + Errors.E931.format( + parent="CoreferenceResolver", method="add_label", name=self.name + ) + ) def add_label(self, label: str) -> int: """Technically this method should be implemented from TrainablePipe, From ba1bf8ae727a4a5ea7fb4207fa12f5acc319972b Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 18:40:05 +0900 Subject: [PATCH 174/188] First take at dimension inference This follows the pattern used in the Biaffine Parser, which uses an init function to get the size only after the tok2vec is available. This works at first, but serialization fails with an error. --- spacy/ml/models/coref.py | 74 +++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 660ef68c544..4967e7f23f8 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,6 +1,6 @@ from typing import List, Tuple -from thinc.api import Model, chain +from thinc.api import Model, chain, get_width from thinc.api import PyTorchWrapper, ArgsKwargs from thinc.types import Floats2d from thinc.util import torch, xp2torch, torch2xp @@ -25,12 +25,48 @@ def build_wl_coref_model( tok2vec_size: int = 768, # tok2vec size ): # TODO add model return types - # dim = tok2vec.maybe_get_dim("n0") + + nI = None with Model.define_operators({">>": chain}): - coref_clusterer = PyTorchWrapper( + coref_clusterer = Model( + "coref_clusterer", + forward=coref_forward, + init=coref_init, + dims={"nI": nI}, + attrs={ + "distance_embedding_size": distance_embedding_size, + "hidden_size": hidden_size, + "depth": depth, + "dropout": dropout, + "antecedent_limit": antecedent_limit, + "antecedent_batch_size": antecedent_batch_size, + }, + ) + + coref_model = tok2vec >> coref_clusterer + return coref_model + + +def coref_init(model: Model, X=None, Y=None): + if model.layers: + return + + if X is not None and model.has_dim("nI") is None: + model.set_dim("nI", get_width(X)) + + hidden_size = model.attrs["hidden_size"] + depth = model.attrs["depth"] + dropout = model.attrs["dropout"] + antecedent_limit = model.attrs["antecedent_limit"] + antecedent_batch_size = model.attrs["antecedent_batch_size"] + distance_embedding_size = model.attrs["distance_embedding_size"] + + PyTorchWrapper = registry.get("layers", "PyTorchWrapper.v2") + model._layers = [ + PyTorchWrapper( CorefClusterer( - tok2vec_size, + model.get_dim("nI"), distance_embedding_size, hidden_size, depth, @@ -41,13 +77,15 @@ def build_wl_coref_model( convert_inputs=convert_coref_clusterer_inputs, convert_outputs=convert_coref_clusterer_outputs, ) - coref_model = tok2vec >> coref_clusterer - return coref_model + # TODO maybe we need mixed precision and grad scaling? + ] -def convert_coref_clusterer_inputs( - model: Model, X: List[Floats2d], is_train: bool -): +def coref_forward(model: Model, X, is_train: bool): + return model.layers[0](X, is_train) + + +def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc # just use the first # TODO real batching @@ -55,7 +93,7 @@ def convert_coref_clusterer_inputs( word_features = xp2torch(X, requires_grad=is_train) # TODO fix or remove type annotations - def backprop(args: ArgsKwargs): #-> List[Floats2d]: + def backprop(args: ArgsKwargs): # -> List[Floats2d]: # convert to xp and wrap in list gradients = torch2xp(args.args[0]) return [gradients] @@ -63,9 +101,7 @@ def backprop(args: ArgsKwargs): #-> List[Floats2d]: return ArgsKwargs(args=(word_features,), kwargs={}), backprop -def convert_coref_clusterer_outputs( - model: Model, inputs_outputs, is_train: bool -): +def convert_coref_clusterer_outputs(model: Model, inputs_outputs, is_train: bool): _, outputs = inputs_outputs scores, indices = outputs @@ -115,9 +151,7 @@ def __init__( self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) pair_emb = dim * 3 + self.pw.shape - self.a_scorer = AnaphoricityScorer( - pair_emb, hidden_size, n_layers, dropout - ) + self.a_scorer = AnaphoricityScorer(pair_emb, hidden_size, n_layers, dropout) self.lstm = torch.nn.LSTM( input_size=dim, hidden_size=dim, @@ -156,10 +190,10 @@ def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tens a_scores_lst: List[torch.Tensor] = [] for i in range(0, len(words), batch_size): - pw_batch = pw[i:i + batch_size] - words_batch = words[i:i + batch_size] - top_indices_batch = top_indices[i:i + batch_size] - top_rough_scores_batch = top_rough_scores[i:i + batch_size] + pw_batch = pw[i : i + batch_size] + words_batch = words[i : i + batch_size] + top_indices_batch = top_indices[i : i + batch_size] + top_rough_scores_batch = top_rough_scores[i : i + batch_size] # a_scores_batch [batch_size, n_ants] a_scores_batch = self.a_scorer( From bd17c38b745771de7589a48646c0951df1c30a8d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 18:58:22 +0900 Subject: [PATCH 175/188] It works! Was missing the serialization-related code from biaffine. --- spacy/ml/models/coref.py | 5 ++-- spacy/pipeline/coref.py | 57 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 4967e7f23f8..377f0223645 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -44,8 +44,9 @@ def build_wl_coref_model( }, ) - coref_model = tok2vec >> coref_clusterer - return coref_model + model = tok2vec >> coref_clusterer + model.set_ref("coref_clusterer", coref_clusterer) + return model def coref_init(model: Model, X=None, Y=None): diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index cd07f80e8f1..ef74a83a411 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -6,6 +6,7 @@ from thinc.api import set_dropout_rate, to_categorical from itertools import islice from statistics import mean +import srsly from .trainable_pipe import TrainablePipe from ..language import Language @@ -13,7 +14,7 @@ from ..errors import Errors from ..tokens import Doc from ..vocab import Vocab -from ..util import registry +from ..util import registry, from_disk, from_bytes from ..ml.models.coref_util import ( create_gold_scores, @@ -316,3 +317,57 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) + + # Store the input dimensionality. nI and nO are not stored explicitly + # for PyTorch models. This makes it tricky to reconstruct the model + # during deserialization. So, besides storing the labels, we also + # store the number of inputs. + coref_clusterer = self.model.get_ref("coref_clusterer") + self.cfg["nI"] = coref_clusterer.get_dim("nI") + + def from_bytes(self, bytes_data, *, exclude=tuple()): + deserializers = { + "cfg": lambda b: self.cfg.update(srsly.json_loads(b)), + "vocab": lambda b: self.vocab.from_bytes(b, exclude=exclude), + } + from_bytes(bytes_data, deserializers, exclude) + + self._initialize_from_disk() + + model_deserializers = { + "model": lambda b: self.model.from_bytes(b), + } + from_bytes(bytes_data, model_deserializers, exclude) + + return self + + def from_disk(self, path, exclude=tuple()): + def load_model(p): + try: + with open(p, "rb") as mfile: + self.model.from_bytes(mfile.read()) + except AttributeError: + raise ValueError(Errors.E149) from None + + deserializers = { + "cfg": lambda p: self.cfg.update(srsly.read_json(p)), + "vocab": lambda p: self.vocab.from_disk(p, exclude=exclude), + } + from_disk(path, deserializers, exclude) + + self._initialize_from_disk() + + model_deserializers = { + "model": load_model, + } + from_disk(path, model_deserializers, exclude) + + return self + + def _initialize_from_disk(self): + # The PyTorch model is constructed lazily, so we need to + # explicitly initialize the model before deserialization. + model = self.model.get_ref("coref_clusterer") + if model.has_dim("nI") is None: + model.set_dim("nI", self.cfg["nI"]) + self.model.initialize() From f67c1735c554be4afafeffbeb10613309d4cb4d0 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 18:58:57 +0900 Subject: [PATCH 176/188] Remove tok2vec_size from coref --- spacy/ml/models/coref.py | 1 - spacy/pipeline/coref.py | 1 - 2 files changed, 2 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 377f0223645..2b38beef783 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -22,7 +22,6 @@ def build_wl_coref_model( # pairs to keep per mention after rough scoring antecedent_limit: int = 50, antecedent_batch_size: int = 512, - tok2vec_size: int = 768, # tok2vec size ): # TODO add model return types diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index ef74a83a411..6685b112e29 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -31,7 +31,6 @@ default_config = """ [model] @architectures = "spacy.Coref.v1" -tok2vec_size = 768 distance_embedding_size = 20 hidden_size = 1024 depth = 1 From b59b924e497ac0674d897e08ee99541ff62d7335 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 19:22:19 +0900 Subject: [PATCH 177/188] Use normal PyTorchWrapper in coref --- spacy/ml/models/coref.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 2b38beef783..7ad493ac0ac 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -62,7 +62,6 @@ def coref_init(model: Model, X=None, Y=None): antecedent_batch_size = model.attrs["antecedent_batch_size"] distance_embedding_size = model.attrs["distance_embedding_size"] - PyTorchWrapper = registry.get("layers", "PyTorchWrapper.v2") model._layers = [ PyTorchWrapper( CorefClusterer( From b0800ea855957da1f26e0c8fa4dbf581f79cf121 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 19:22:37 +0900 Subject: [PATCH 178/188] Do dimension inference in span predictor --- spacy/ml/models/span_predictor.py | 50 ++++++++++++++++++++++++----- spacy/pipeline/span_predictor.py | 52 +++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index d44e632bdc7..55a966c1dea 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -13,7 +13,6 @@ @registry.architectures("spacy.SpanPredictor.v1") def build_span_predictor( tok2vec: Model[List[Doc], List[Floats2d]], - tok2vec_size: int = 768, hidden_size: int = 1024, distance_embedding_size: int = 64, conv_channels: int = 4, @@ -24,23 +23,58 @@ def build_span_predictor( # TODO add model return types with Model.define_operators({">>": chain, "&": tuplify}): - span_predictor = PyTorchWrapper( + span_predictor = Model( + "span_predictor", + forward=span_predictor_forward, + init=span_predictor_init, + dims={"nI": nI}, + attrs={ + "distance_embedding_size": distance_embedding_size, + "hidden_size": hidden_size, + "conv_channels": conv_channels, + "window_size": window_size, + "max_distance": max_distance, + "prefix": prefix, + }, + ) + head_info = build_get_head_metadata(prefix) + model = (tok2vec & head_info) >> span_predictor + model.set_ref("span_predictor", span_predictor) + + return model + +def span_predictor_init(model: Model, X=None, Y=None): + if model.layers: + return + + if X is not None and model.has_dim("nI") is None: + model.set_dim("nI", get_width(X)) + + hidden_size = model.attrs["hidden_size"] + distance_embedding_size = model.attrs["distance_embedding_size"] + conv_channels = model.attrs["conv_channels"] + window_size = model.attrs["window_size"] + max_distance = model.attrs["max_distance"] + prefix = model.attrs["prefix"] + + model._layers = [ + PyTorchWrapper( SpanPredictor( - tok2vec_size, + model.get_dim("nI"), hidden_size, distance_embedding_size, conv_channels, window_size, max_distance, + prefix, ), convert_inputs=convert_span_predictor_inputs, ) - # TODO use proper parameter for prefix - head_info = build_get_head_metadata(prefix) - model = (tok2vec & head_info) >> span_predictor - - return model + # TODO maybe we need mixed precision and grad scaling? + ] +def span_predictor_forward(model: Model, X, is_train: bool): + return model.layers[0](X, is_train) def convert_span_predictor_inputs( model: Model, X: Tuple[List[Floats2d], Tuple[List[Ints1d], List[Ints1d]]], is_train: bool diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index d7e96a4b294..eed6ce9f812 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -5,6 +5,7 @@ from thinc.api import Model, Config, Optimizer, CategoricalCrossentropy from thinc.api import set_dropout_rate, to_categorical from itertools import islice +import srsly from .trainable_pipe import TrainablePipe from ..language import Language @@ -13,7 +14,7 @@ from ..scorer import Scorer, doc2clusters from ..tokens import Doc from ..vocab import Vocab -from ..util import registry +from ..util import registry, from_bytes, from_disk from ..ml.models.coref_util import ( MentionClusters, @@ -23,7 +24,6 @@ default_span_predictor_config = """ [model] @architectures = "spacy.SpanPredictor.v1" -tok2vec_size = 768 hidden_size = 1024 distance_embedding_size = 64 conv_channels = 4 @@ -274,3 +274,51 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) + + + def from_bytes(self, bytes_data, *, exclude=tuple()): + deserializers = { + "cfg": lambda b: self.cfg.update(srsly.json_loads(b)), + "vocab": lambda b: self.vocab.from_bytes(b, exclude=exclude), + } + from_bytes(bytes_data, deserializers, exclude) + + self._initialize_from_disk() + + model_deserializers = { + "model": lambda b: self.model.from_bytes(b), + } + from_bytes(bytes_data, model_deserializers, exclude) + + return self + + def from_disk(self, path, exclude=tuple()): + def load_model(p): + try: + with open(p, "rb") as mfile: + self.model.from_bytes(mfile.read()) + except AttributeError: + raise ValueError(Errors.E149) from None + + deserializers = { + "cfg": lambda p: self.cfg.update(srsly.read_json(p)), + "vocab": lambda p: self.vocab.from_disk(p, exclude=exclude), + } + from_disk(path, deserializers, exclude) + + self._initialize_from_disk() + + model_deserializers = { + "model": load_model, + } + from_disk(path, model_deserializers, exclude) + + return self + + def _initialize_from_disk(self): + # The PyTorch model is constructed lazily, so we need to + # explicitly initialize the model before deserialization. + model = self.model.get_ref("span_predictor") + if model.has_dim("nI") is None: + model.set_dim("nI", self.cfg["nI"]) + self.model.initialize() From da81a90d64dbb156ba3abafcf7aaa6ff8d126e81 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Wed, 6 Jul 2022 19:29:27 +0900 Subject: [PATCH 179/188] Span predictor leftovers --- spacy/ml/models/span_predictor.py | 7 +++---- spacy/pipeline/span_predictor.py | 6 ++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 55a966c1dea..4e394ed7826 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -1,6 +1,6 @@ from typing import List, Tuple -from thinc.api import Model, chain, tuplify +from thinc.api import Model, chain, tuplify, get_width from thinc.api import PyTorchWrapper, ArgsKwargs from thinc.types import Floats2d, Ints1d from thinc.util import torch, xp2torch, torch2xp @@ -22,6 +22,8 @@ def build_span_predictor( ): # TODO add model return types + nI = None + with Model.define_operators({">>": chain, "&": tuplify}): span_predictor = Model( "span_predictor", @@ -34,7 +36,6 @@ def build_span_predictor( "conv_channels": conv_channels, "window_size": window_size, "max_distance": max_distance, - "prefix": prefix, }, ) head_info = build_get_head_metadata(prefix) @@ -55,7 +56,6 @@ def span_predictor_init(model: Model, X=None, Y=None): conv_channels = model.attrs["conv_channels"] window_size = model.attrs["window_size"] max_distance = model.attrs["max_distance"] - prefix = model.attrs["prefix"] model._layers = [ PyTorchWrapper( @@ -66,7 +66,6 @@ def span_predictor_init(model: Model, X=None, Y=None): conv_channels, window_size, max_distance, - prefix, ), convert_inputs=convert_span_predictor_inputs, ) diff --git a/spacy/pipeline/span_predictor.py b/spacy/pipeline/span_predictor.py index eed6ce9f812..b5f25cd8168 100644 --- a/spacy/pipeline/span_predictor.py +++ b/spacy/pipeline/span_predictor.py @@ -275,6 +275,12 @@ def initialize( assert len(X) > 0, Errors.E923.format(name=self.name) self.model.initialize(X=X, Y=Y) + # Store the input dimensionality. nI and nO are not stored explicitly + # for PyTorch models. This makes it tricky to reconstruct the model + # during deserialization. So, besides storing the labels, we also + # store the number of inputs. + span_predictor = self.model.get_ref("span_predictor") + self.cfg["nI"] = span_predictor.get_dim("nI") def from_bytes(self, bytes_data, *, exclude=tuple()): deserializers = { From 2eee0d248ee61aeac578e0cd021496c7d4b6c255 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Fri, 8 Jul 2022 18:29:14 +0900 Subject: [PATCH 180/188] Fix types mypy now exits without an error, except for two apparently unrelated ones about setup.py. --- spacy/ml/models/coref.py | 37 +++++++++++++------------------ spacy/ml/models/span_predictor.py | 16 ++++++------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index 660ef68c544..8b262ad3b0e 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -1,8 +1,8 @@ -from typing import List, Tuple +from typing import List, Tuple, Callable, cast from thinc.api import Model, chain from thinc.api import PyTorchWrapper, ArgsKwargs -from thinc.types import Floats2d +from thinc.types import Floats2d, Ints2d from thinc.util import torch, xp2torch, torch2xp from ...tokens import Doc @@ -23,9 +23,7 @@ def build_wl_coref_model( antecedent_limit: int = 50, antecedent_batch_size: int = 512, tok2vec_size: int = 768, # tok2vec size -): - # TODO add model return types - # dim = tok2vec.maybe_get_dim("n0") +) -> Model[List[Doc], Tuple[Floats2d, Ints2d]]: with Model.define_operators({">>": chain}): coref_clusterer = PyTorchWrapper( @@ -45,27 +43,24 @@ def build_wl_coref_model( return coref_model -def convert_coref_clusterer_inputs( - model: Model, X: List[Floats2d], is_train: bool -): +def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc # just use the first # TODO real batching X = X[0] word_features = xp2torch(X, requires_grad=is_train) - # TODO fix or remove type annotations - def backprop(args: ArgsKwargs): #-> List[Floats2d]: + def backprop(args: ArgsKwargs) -> List[Floats2d]: # convert to xp and wrap in list - gradients = torch2xp(args.args[0]) + gradients = cast(Floats2d, torch2xp(args.args[0])) return [gradients] return ArgsKwargs(args=(word_features,), kwargs={}), backprop def convert_coref_clusterer_outputs( - model: Model, inputs_outputs, is_train: bool -): + model: Model, inputs_outputs, is_train: bool +) -> Tuple[Tuple[Floats2d, Ints2d], Callable]: _, outputs = inputs_outputs scores, indices = outputs @@ -76,8 +71,8 @@ def convert_for_torch_backward(dY: Floats2d) -> ArgsKwargs: kwargs={"grad_tensors": [dY_t]}, ) - scores_xp = torch2xp(scores) - indices_xp = torch2xp(indices) + scores_xp = cast(Floats2d, torch2xp(scores)) + indices_xp = cast(Ints2d, torch2xp(indices)) return (scores_xp, indices_xp), convert_for_torch_backward @@ -115,9 +110,7 @@ def __init__( self.pw = DistancePairwiseEncoder(dist_emb_size, dropout) pair_emb = dim * 3 + self.pw.shape - self.a_scorer = AnaphoricityScorer( - pair_emb, hidden_size, n_layers, dropout - ) + self.a_scorer = AnaphoricityScorer(pair_emb, hidden_size, n_layers, dropout) self.lstm = torch.nn.LSTM( input_size=dim, hidden_size=dim, @@ -156,10 +149,10 @@ def forward(self, word_features: torch.Tensor) -> Tuple[torch.Tensor, torch.Tens a_scores_lst: List[torch.Tensor] = [] for i in range(0, len(words), batch_size): - pw_batch = pw[i:i + batch_size] - words_batch = words[i:i + batch_size] - top_indices_batch = top_indices[i:i + batch_size] - top_rough_scores_batch = top_rough_scores[i:i + batch_size] + pw_batch = pw[i : i + batch_size] + words_batch = words[i : i + batch_size] + top_indices_batch = top_indices[i : i + batch_size] + top_rough_scores_batch = top_rough_scores[i : i + batch_size] # a_scores_batch [batch_size, n_ants] a_scores_batch = self.a_scorer( diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index f11ecb5d55b..1947b7833da 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import List, Tuple, cast from thinc.api import Model, chain, tuplify from thinc.api import PyTorchWrapper, ArgsKwargs @@ -42,15 +42,17 @@ def build_span_predictor( def convert_span_predictor_inputs( - model: Model, X: Tuple[List[Floats2d], Tuple[List[Ints1d], List[Ints1d]]], is_train: bool + model: Model, + X: Tuple[List[Floats2d], Tuple[List[Ints1d], List[Ints1d]]], + is_train: bool, ): tok2vec, (sent_ids, head_ids) = X # Normally we should use the input is_train, but for these two it's not relevant # TODO fix the type here, or remove it - def backprop(args: ArgsKwargs): #-> Tuple[List[Floats2d], None]: - gradients = torch2xp(args.args[1]) + def backprop(args: ArgsKwargs) -> Tuple[List[Floats2d], None]: + gradients = cast(Floats2d, torch2xp(args.args[1])) # The sent_ids and head_ids are None because no gradients - return [[gradients], None] + return ([gradients], None) word_features = xp2torch(tok2vec[0], requires_grad=is_train) sent_ids_tensor = xp2torch(sent_ids[0], requires_grad=False) @@ -207,9 +209,7 @@ def forward( dim=1, ) lengths = same_sent.sum(dim=1) - padding_mask = torch.arange( - 0, lengths.max().item(), device=device - ).unsqueeze(0) + padding_mask = torch.arange(0, lengths.max().item(), device=device).unsqueeze(0) # (n_heads x max_sent_len) padding_mask = padding_mask < lengths.unsqueeze(1) # (n_heads x max_sent_len x input_size * 2 + distance_emb_size) From baeb35f31be3c42720482ea5f9e0ee068524c5a0 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 11 Jul 2022 20:03:29 +0900 Subject: [PATCH 181/188] Add type annotations for internal models --- spacy/ml/models/coref.py | 5 +++-- spacy/ml/models/span_predictor.py | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/spacy/ml/models/coref.py b/spacy/ml/models/coref.py index a69c6673eaf..8befeaef384 100644 --- a/spacy/ml/models/coref.py +++ b/spacy/ml/models/coref.py @@ -22,11 +22,11 @@ def build_wl_coref_model( # pairs to keep per mention after rough scoring antecedent_limit: int = 50, antecedent_batch_size: int = 512, - nI = None + nI=None, ) -> Model[List[Doc], Tuple[Floats2d, Ints2d]]: with Model.define_operators({">>": chain}): - coref_clusterer = Model( + coref_clusterer: Model[List[Floats2d], Tuple[Floats2d, Ints2d]] = Model( "coref_clusterer", forward=coref_forward, init=coref_init, @@ -81,6 +81,7 @@ def coref_init(model: Model, X=None, Y=None): def coref_forward(model: Model, X, is_train: bool): return model.layers[0](X, is_train) + def convert_coref_clusterer_inputs(model: Model, X: List[Floats2d], is_train: bool): # The input here is List[Floats2d], one for each doc # just use the first diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index ca76e5a4a95..8a93dcf8e34 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -25,7 +25,7 @@ def build_span_predictor( nI = None with Model.define_operators({">>": chain, "&": tuplify}): - span_predictor = Model( + span_predictor: Model[List[Floats2d], List[Floats2d]] = Model( "span_predictor", forward=span_predictor_forward, init=span_predictor_init, @@ -44,6 +44,7 @@ def build_span_predictor( return model + def span_predictor_init(model: Model, X=None, Y=None): if model.layers: return @@ -72,9 +73,11 @@ def span_predictor_init(model: Model, X=None, Y=None): # TODO maybe we need mixed precision and grad scaling? ] + def span_predictor_forward(model: Model, X, is_train: bool): return model.layers[0](X, is_train) + def convert_span_predictor_inputs( model: Model, X: Tuple[List[Floats2d], Tuple[List[Ints1d], List[Ints1d]]], @@ -95,7 +98,9 @@ def backprop(args: ArgsKwargs) -> Tuple[List[Floats2d], None]: else: head_ids_tensor = xp2torch(head_ids[0], requires_grad=False) - argskwargs = ArgsKwargs(args=(sent_ids_tensor, word_features, head_ids_tensor), kwargs={}) + argskwargs = ArgsKwargs( + args=(sent_ids_tensor, word_features, head_ids_tensor), kwargs={} + ) return argskwargs, backprop From f9c82e249c8fd8e74114c95140b67e6557c39e08 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 11 Jul 2022 20:14:36 +0900 Subject: [PATCH 182/188] Update error number This was changed by merge --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 40ff92fcbb9..2532a9fbf0b 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -289,7 +289,7 @@ def get_loss( span = example.predicted.char_span(start_char, end_char) if span is None: # TODO log more details - raise IndexError(Errors.E1043) + raise IndexError(Errors.E1044) cc.append((span.start, span.end)) clusters.append(cc) From 0f3c45646b9077e156e4f9a5c87e7bb42d576f4d Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Mon, 11 Jul 2022 20:14:36 +0900 Subject: [PATCH 183/188] Update error number This was changed by merge --- spacy/pipeline/coref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/pipeline/coref.py b/spacy/pipeline/coref.py index 7cf4fa44af0..b58441bf941 100644 --- a/spacy/pipeline/coref.py +++ b/spacy/pipeline/coref.py @@ -289,7 +289,7 @@ def get_loss( span = example.predicted.char_span(start_char, end_char) if span is None: # TODO log more details - raise IndexError(Errors.E1043) + raise IndexError(Errors.E1044) cc.append((span.start, span.end)) clusters.append(cc) From 1baa334b8a49c8dd7b821af5ba3dc6fae6be02de Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 12 Jul 2022 14:07:40 +0900 Subject: [PATCH 184/188] Make get_clusters_from_doc return spans in order There's no guarantee about the order in which SpanGroup keys will come out, so access them in sorted order when doing comparisons. --- spacy/ml/models/coref_util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spacy/ml/models/coref_util.py b/spacy/ml/models/coref_util.py index 1a6bc636499..e374360097c 100644 --- a/spacy/ml/models/coref_util.py +++ b/spacy/ml/models/coref_util.py @@ -147,7 +147,9 @@ def get_clusters_from_doc(doc) -> List[List[Tuple[int, int]]]: ints are char spans, to be tokenization independent. """ out = [] - for key, val in doc.spans.items(): + keys = sorted(list(doc.spans.keys())) + for key in keys: + val = doc.spans[key] cluster = [] for span in val: From 07e8556cc342236e1d1f6f733dab943ce88fbb80 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 12 Jul 2022 14:08:35 +0900 Subject: [PATCH 185/188] Remove config from coref tests This was necessary when the tok2vec_size option was necessary. --- spacy/tests/pipeline/test_coref.py | 15 ++++++--------- spacy/tests/pipeline/test_span_predictor.py | 10 ++++------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/spacy/tests/pipeline/test_coref.py b/spacy/tests/pipeline/test_coref.py index 3e297ddcdf1..b29c4243d6e 100644 --- a/spacy/tests/pipeline/test_coref.py +++ b/spacy/tests/pipeline/test_coref.py @@ -36,9 +36,6 @@ # fmt: on -CONFIG = {"model": {"@architectures": "spacy.Coref.v1", "tok2vec_size": 64}} - - @pytest.fixture def nlp(): return English() @@ -67,7 +64,7 @@ def test_not_initialized(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized(nlp): - nlp.add_pipe("coref", config=CONFIG) + nlp.add_pipe("coref") nlp.initialize() assert nlp.pipe_names == ["coref"] text = "She gave me her pen." @@ -79,7 +76,7 @@ def test_initialized(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_initialized_short(nlp): - nlp.add_pipe("coref", config=CONFIG) + nlp.add_pipe("coref") nlp.initialize() assert nlp.pipe_names == ["coref"] text = "Hi there" @@ -89,7 +86,7 @@ def test_initialized_short(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_coref_serialization(nlp): # Test that the coref component can be serialized - nlp.add_pipe("coref", last=True, config=CONFIG) + nlp.add_pipe("coref", last=True) nlp.initialize() assert nlp.pipe_names == ["coref"] text = "She gave me her pen." @@ -111,7 +108,7 @@ def test_overfitting_IO(nlp): for text, annot in TRAIN_DATA: train_examples.append(Example.from_dict(nlp.make_doc(text), annot)) - nlp.add_pipe("coref", config=CONFIG) + nlp.add_pipe("coref") optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) @@ -166,7 +163,7 @@ def test_tokenization_mismatch(nlp): train_examples.append(eg) - nlp.add_pipe("coref", config=CONFIG) + nlp.add_pipe("coref") optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) @@ -228,7 +225,7 @@ def test_whitespace_mismatch(nlp): eg.predicted = nlp.make_doc(" " + text) train_examples.append(eg) - nlp.add_pipe("coref", config=CONFIG) + nlp.add_pipe("coref") optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) diff --git a/spacy/tests/pipeline/test_span_predictor.py b/spacy/tests/pipeline/test_span_predictor.py index 8a6c62011ad..8083783cd66 100644 --- a/spacy/tests/pipeline/test_span_predictor.py +++ b/spacy/tests/pipeline/test_span_predictor.py @@ -44,8 +44,6 @@ ] # fmt: on -CONFIG = {"model": {"@architectures": "spacy.SpanPredictor.v1", "tok2vec_size": 64}} - @pytest.fixture def nlp(): @@ -76,7 +74,7 @@ def test_not_initialized(nlp): @pytest.mark.skipif(not has_torch, reason="Torch not available") def test_span_predictor_serialization(nlp): # Test that the span predictor component can be serialized - nlp.add_pipe("span_predictor", last=True, config=CONFIG) + nlp.add_pipe("span_predictor", last=True) nlp.initialize() assert nlp.pipe_names == ["span_predictor"] text = "She gave me her pen." @@ -109,7 +107,7 @@ def test_overfitting_IO(nlp): pred.spans[key] = [pred[span.start : span.end] for span in spans] train_examples.append(eg) - nlp.add_pipe("span_predictor", config=CONFIG) + nlp.add_pipe("span_predictor") optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) @@ -173,7 +171,7 @@ def test_tokenization_mismatch(nlp): train_examples.append(eg) - nlp.add_pipe("span_predictor", config=CONFIG) + nlp.add_pipe("span_predictor") optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) @@ -218,7 +216,7 @@ def test_whitespace_mismatch(nlp): eg.predicted = nlp.make_doc(" " + text) train_examples.append(eg) - nlp.add_pipe("span_predictor", config=CONFIG) + nlp.add_pipe("span_predictor") optimizer = nlp.initialize() test_text = TRAIN_DATA[0][0] doc = nlp(test_text) From 2e9dadfda40c36641891a098827fa10bddbd52af Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Tue, 12 Jul 2022 16:06:15 +0900 Subject: [PATCH 186/188] Remove orphaned function This was probably used in the prototyping stage, left as a reference, and then forgotten. Nothing uses it any more. --- spacy/ml/models/span_predictor.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/spacy/ml/models/span_predictor.py b/spacy/ml/models/span_predictor.py index 8a93dcf8e34..715a89d9718 100644 --- a/spacy/ml/models/span_predictor.py +++ b/spacy/ml/models/span_predictor.py @@ -104,37 +104,6 @@ def backprop(args: ArgsKwargs) -> Tuple[List[Floats2d], None]: return argskwargs, backprop -# TODO This probably belongs in the component, not the model. -def predict_span_clusters( - span_predictor: Model, sent_ids: Ints1d, words: Floats2d, clusters: List[Ints1d] -): - """ - Predicts span clusters based on the word clusters. - - span_predictor: a SpanPredictor instance - sent_ids: For each word indicates, which sentence it appears in. - words: Features for words. - clusters: Clusters inferred by the CorefScorer. - - Returns: - List[List[Tuple[int, int]]: span clusters - """ - if not clusters: - return [] - - xp = span_predictor.ops.xp - heads_ids = xp.asarray(sorted(i for cluster in clusters for i in cluster)) - scores = span_predictor.predict((sent_ids, words, heads_ids)) - starts = scores[:, :, 0].argmax(axis=1).tolist() - ends = (scores[:, :, 1].argmax(axis=1) + 1).tolist() - - head2span = { - head: (start, end) for head, start, end in zip(heads_ids.tolist(), starts, ends) - } - - return [[head2span[head] for head in cluster] for cluster in clusters] - - def build_get_head_metadata(prefix): model = Model( "HeadDataProvider", attrs={"prefix": prefix}, forward=head_data_forward From 3a7658e052573191eef939c6929a0d23c36cbdd6 Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 4 Aug 2022 15:09:31 +0900 Subject: [PATCH 187/188] Update docs to mark experimental, rename SpanPredictor to SpanResolver --- website/docs/api/coref.md | 21 ++- .../{span-predictor.md => span-resolver.md} | 145 ++++++++++-------- 2 files changed, 100 insertions(+), 66 deletions(-) rename website/docs/api/{span-predictor.md => span-resolver.md} (76%) diff --git a/website/docs/api/coref.md b/website/docs/api/coref.md index 4d43645f328..e508ea4299f 100644 --- a/website/docs/api/coref.md +++ b/website/docs/api/coref.md @@ -1,7 +1,7 @@ --- title: CoreferenceResolver -tag: class -source: spacy/pipeline/coref.py +tag: class,experimental +source: spacy-experimental/coref/coref_component.py new: 3.4 teaser: 'Pipeline component for word-level coreference resolution' api_base_class: /api/pipe @@ -9,6 +9,23 @@ api_string_name: coref api_trainable: true --- +> #### Installation +> +> ```bash +> $ pip install -U spacy-experimental +> ``` + + + +This component not yet integrated into spaCy core, and is available via the extension package +[`spacy-experimental`](https://github.com/explosion/spacy-transformers). It +exposes the component via entry points, so if you have the package installed, +using `factory = "coref"` in your +[training config](/usage/training#config) or `nlp.add_pipe("coref")` will +work out-of-the-box. + + + A `CoreferenceResolver` component groups tokens into clusters that refer to the same thing. Clusters are represented as SpanGroups that start with a prefix (`coref_clusters_` by default). diff --git a/website/docs/api/span-predictor.md b/website/docs/api/span-resolver.md similarity index 76% rename from website/docs/api/span-predictor.md rename to website/docs/api/span-resolver.md index 1e99b49b2b2..1b33bc29964 100644 --- a/website/docs/api/span-predictor.md +++ b/website/docs/api/span-resolver.md @@ -1,15 +1,32 @@ --- -title: SpanPredictor -tag: class -source: spacy/pipeline/span_predictor.py +title: SpanResolver +tag: class,experimental +source: spacy-experimental/coref/span_resolver_component.py new: 3.4 teaser: 'Pipeline component for resolving tokens into spans' api_base_class: /api/pipe -api_string_name: span_predictor +api_string_name: span_resolver api_trainable: true --- -A `SpanPredictor` component takes in tokens (represented as `Span`s of length +> #### Installation +> +> ```bash +> $ pip install -U spacy-experimental +> ``` + + + +This component not yet integrated into spaCy core, and is available via the extension package +[`spacy-experimental`](https://github.com/explosion/spacy-transformers). It +exposes the component via entry points, so if you have the package installed, +using `factory = "span_resolver"` in your +[training config](/usage/training#config) or `nlp.add_pipe("span_resolver")` will +work out-of-the-box. + + + +A `SpanResolver` component takes in tokens (represented as `Span`s of length 1. and resolves them into `Span`s of arbitrary length. The initial use case is as a post-processing step on word-level [coreference resolution](/api/coref). @@ -40,39 +57,39 @@ architectures and their arguments and hyperparameters. > #### Example > > ```python -> from spacy.pipeline.span_predictor import DEFAULT_SPAN_PREDICTOR_MODEL +> from spacy.pipeline.span_resolver import DEFAULT_span_resolver_MODEL > config={ -> "model": DEFAULT_SPAN_PREDICTOR_MODEL, +> "model": DEFAULT_span_resolver_MODEL, > "span_cluster_prefix": DEFAULT_CLUSTER_PREFIX, > }, -> nlp.add_pipe("span_predictor", config=config) +> nlp.add_pipe("span_resolver", config=config) > ``` | Setting | Description | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `model` | The [`Model`](https://thinc.ai/docs/api-model) powering the pipeline component. Defaults to [SpanPredictor](/api/architectures#SpanPredictor). ~~Model~~ | +| `model` | The [`Model`](https://thinc.ai/docs/api-model) powering the pipeline component. Defaults to [SpanResolver](/api/architectures#SpanResolver). ~~Model~~ | | `input_prefix` | The prefix to use for input `SpanGroup`s. Defaults to `coref_head_clusters`. ~~str~~ | | `output_prefix` | The prefix for predicted `SpanGroup`s. Defaults to `coref_clusters`. ~~str~~ | ```python -%%GITHUB_SPACY/spacy/pipeline/span_predictor.py +%%GITHUB_SPACY/spacy/pipeline/span_resolver.py ``` -## SpanPredictor.\_\_init\_\_ {#init tag="method"} +## SpanResolver.\_\_init\_\_ {#init tag="method"} > #### Example > > ```python > # Construction via add_pipe with default model -> span_predictor = nlp.add_pipe("span_predictor") +> span_resolver = nlp.add_pipe("span_resolver") > > # Construction via add_pipe with custom model -> config = {"model": {"@architectures": "my_span_predictor.v1"}} -> span_predictor = nlp.add_pipe("span_predictor", config=config) +> config = {"model": {"@architectures": "my_span_resolver.v1"}} +> span_resolver = nlp.add_pipe("span_resolver", config=config) > > # Construction from class -> from spacy.pipeline import SpanPredictor -> span_predictor = SpanPredictor(nlp.vocab, model) +> from spacy.pipeline import SpanResolver +> span_resolver = SpanResolver(nlp.vocab, model) > ``` Create a new pipeline instance. In your application, you would normally use a @@ -88,7 +105,7 @@ shortcut for this and instantiate the component using its string name and | `input_prefix` | The prefix to use for input `SpanGroup`s. Defaults to `coref_head_clusters`. ~~str~~ | | `output_prefix` | The prefix for predicted `SpanGroup`s. Defaults to `coref_clusters`. ~~str~~ | -## SpanPredictor.\_\_call\_\_ {#call tag="method"} +## SpanResolver.\_\_call\_\_ {#call tag="method"} Apply the pipe to one document. The document is modified in place and returned. This usually happens under the hood when the `nlp` object is called on a text @@ -100,9 +117,9 @@ and [`set_annotations`](#set_annotations) methods. > > ```python > doc = nlp("This is a sentence.") -> span_predictor = nlp.add_pipe("span_predictor") +> span_resolver = nlp.add_pipe("span_resolver") > # This usually happens under the hood -> processed = span_predictor(doc) +> processed = span_resolver(doc) > ``` | Name | Description | @@ -110,20 +127,20 @@ and [`set_annotations`](#set_annotations) methods. | `doc` | The document to process. ~~Doc~~ | | **RETURNS** | The processed document. ~~Doc~~ | -## SpanPredictor.pipe {#pipe tag="method"} +## SpanResolver.pipe {#pipe tag="method"} Apply the pipe to a stream of documents. This usually happens under the hood when the `nlp` object is called on a text and all pipeline components are -applied to the `Doc` in order. Both [`__call__`](/api/span-predictor#call) and -[`pipe`](/api/span-predictor#pipe) delegate to the -[`predict`](/api/span-predictor#predict) and -[`set_annotations`](/api/span-predictor#set_annotations) methods. +applied to the `Doc` in order. Both [`__call__`](/api/span-resolver#call) and +[`pipe`](/api/span-resolver#pipe) delegate to the +[`predict`](/api/span-resolver#predict) and +[`set_annotations`](/api/span-resolver#set_annotations) methods. > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> for doc in span_predictor.pipe(docs, batch_size=50): +> span_resolver = nlp.add_pipe("span_resolver") +> for doc in span_resolver.pipe(docs, batch_size=50): > pass > ``` @@ -134,7 +151,7 @@ applied to the `Doc` in order. Both [`__call__`](/api/span-predictor#call) and | `batch_size` | The number of documents to buffer. Defaults to `128`. ~~int~~ | | **YIELDS** | The processed documents in order. ~~Doc~~ | -## SpanPredictor.initialize {#initialize tag="method"} +## SpanResolver.initialize {#initialize tag="method"} Initialize the component for training. `get_examples` should be a function that returns an iterable of [`Example`](/api/example) objects. The data examples are @@ -148,8 +165,8 @@ by [`Language.initialize`](/api/language#initialize). > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> span_predictor.initialize(lambda: [], nlp=nlp) +> span_resolver = nlp.add_pipe("span_resolver") +> span_resolver.initialize(lambda: [], nlp=nlp) > ``` | Name | Description | @@ -158,7 +175,7 @@ by [`Language.initialize`](/api/language#initialize). | _keyword-only_ | | | `nlp` | The current `nlp` object. Defaults to `None`. ~~Optional[Language]~~ | -## SpanPredictor.predict {#predict tag="method"} +## SpanResolver.predict {#predict tag="method"} Apply the component's model to a batch of [`Doc`](/api/doc) objects, without modifying them. Predictions are returned as a list of `MentionClusters`, one for @@ -169,8 +186,8 @@ correspond to token indices. > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> spans = span_predictor.predict([doc1, doc2]) +> span_resolver = nlp.add_pipe("span_resolver") +> spans = span_resolver.predict([doc1, doc2]) > ``` | Name | Description | @@ -178,7 +195,7 @@ correspond to token indices. | `docs` | The documents to predict. ~~Iterable[Doc]~~ | | **RETURNS** | The predicted spans for the `Doc`s. ~~List[MentionClusters]~~ | -## SpanPredictor.set_annotations {#set_annotations tag="method"} +## SpanResolver.set_annotations {#set_annotations tag="method"} Modify a batch of documents, saving predictions using the output prefix in `Doc.spans`. @@ -186,9 +203,9 @@ Modify a batch of documents, saving predictions using the output prefix in > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> spans = span_predictor.predict([doc1, doc2]) -> span_predictor.set_annotations([doc1, doc2], spans) +> span_resolver = nlp.add_pipe("span_resolver") +> spans = span_resolver.predict([doc1, doc2]) +> span_resolver.set_annotations([doc1, doc2], spans) > ``` | Name | Description | @@ -196,17 +213,17 @@ Modify a batch of documents, saving predictions using the output prefix in | `docs` | The documents to modify. ~~Iterable[Doc]~~ | | `spans` | The predicted spans for the `docs`. ~~List[MentionClusters]~~ | -## SpanPredictor.update {#update tag="method"} +## SpanResolver.update {#update tag="method"} Learn from a batch of [`Example`](/api/example) objects. Delegates to -[`predict`](/api/span-predictor#predict). +[`predict`](/api/span-resolver#predict). > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") +> span_resolver = nlp.add_pipe("span_resolver") > optimizer = nlp.initialize() -> losses = span_predictor.update(examples, sgd=optimizer) +> losses = span_resolver.update(examples, sgd=optimizer) > ``` | Name | Description | @@ -218,22 +235,22 @@ Learn from a batch of [`Example`](/api/example) objects. Delegates to | `losses` | Optional record of the loss during training. Updated using the component name as the key. ~~Optional[Dict[str, float]]~~ | | **RETURNS** | The updated `losses` dictionary. ~~Dict[str, float]~~ | -## SpanPredictor.create_optimizer {#create_optimizer tag="method"} +## SpanResolver.create_optimizer {#create_optimizer tag="method"} Create an optimizer for the pipeline component. > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> optimizer = span_predictor.create_optimizer() +> span_resolver = nlp.add_pipe("span_resolver") +> optimizer = span_resolver.create_optimizer() > ``` | Name | Description | | ----------- | ---------------------------- | | **RETURNS** | The optimizer. ~~Optimizer~~ | -## SpanPredictor.use_params {#use_params tag="method, contextmanager"} +## SpanResolver.use_params {#use_params tag="method, contextmanager"} Modify the pipe's model, to use the given parameter values. At the end of the context, the original parameters are restored. @@ -241,24 +258,24 @@ context, the original parameters are restored. > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> with span_predictor.use_params(optimizer.averages): -> span_predictor.to_disk("/best_model") +> span_resolver = nlp.add_pipe("span_resolver") +> with span_resolver.use_params(optimizer.averages): +> span_resolver.to_disk("/best_model") > ``` | Name | Description | | -------- | -------------------------------------------------- | | `params` | The parameter values to use in the model. ~~dict~~ | -## SpanPredictor.to_disk {#to_disk tag="method"} +## SpanResolver.to_disk {#to_disk tag="method"} Serialize the pipe to disk. > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> span_predictor.to_disk("/path/to/span_predictor") +> span_resolver = nlp.add_pipe("span_resolver") +> span_resolver.to_disk("/path/to/span_resolver") > ``` | Name | Description | @@ -267,15 +284,15 @@ Serialize the pipe to disk. | _keyword-only_ | | | `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | -## SpanPredictor.from_disk {#from_disk tag="method"} +## SpanResolver.from_disk {#from_disk tag="method"} Load the pipe from disk. Modifies the object in place and returns it. > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> span_predictor.from_disk("/path/to/span_predictor") +> span_resolver = nlp.add_pipe("span_resolver") +> span_resolver.from_disk("/path/to/span_resolver") > ``` | Name | Description | @@ -283,15 +300,15 @@ Load the pipe from disk. Modifies the object in place and returns it. | `path` | A path to a directory. Paths may be either strings or `Path`-like objects. ~~Union[str, Path]~~ | | _keyword-only_ | | | `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | -| **RETURNS** | The modified `SpanPredictor` object. ~~SpanPredictor~~ | +| **RETURNS** | The modified `SpanResolver` object. ~~SpanResolver~~ | -## SpanPredictor.to_bytes {#to_bytes tag="method"} +## SpanResolver.to_bytes {#to_bytes tag="method"} > #### Example > > ```python -> span_predictor = nlp.add_pipe("span_predictor") -> span_predictor_bytes = span_predictor.to_bytes() +> span_resolver = nlp.add_pipe("span_resolver") +> span_resolver_bytes = span_resolver.to_bytes() > ``` Serialize the pipe to a bytestring. @@ -300,18 +317,18 @@ Serialize the pipe to a bytestring. | -------------- | ------------------------------------------------------------------------------------------- | | _keyword-only_ | | | `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | -| **RETURNS** | The serialized form of the `SpanPredictor` object. ~~bytes~~ | +| **RETURNS** | The serialized form of the `SpanResolver` object. ~~bytes~~ | -## SpanPredictor.from_bytes {#from_bytes tag="method"} +## SpanResolver.from_bytes {#from_bytes tag="method"} Load the pipe from a bytestring. Modifies the object in place and returns it. > #### Example > > ```python -> span_predictor_bytes = span_predictor.to_bytes() -> span_predictor = nlp.add_pipe("span_predictor") -> span_predictor.from_bytes(span_predictor_bytes) +> span_resolver_bytes = span_resolver.to_bytes() +> span_resolver = nlp.add_pipe("span_resolver") +> span_resolver.from_bytes(span_resolver_bytes) > ``` | Name | Description | @@ -319,7 +336,7 @@ Load the pipe from a bytestring. Modifies the object in place and returns it. | `bytes_data` | The data to load from. ~~bytes~~ | | _keyword-only_ | | | `exclude` | String names of [serialization fields](#serialization-fields) to exclude. ~~Iterable[str]~~ | -| **RETURNS** | The `SpanPredictor` object. ~~SpanPredictor~~ | +| **RETURNS** | The `SpanResolver` object. ~~SpanResolver~~ | ## Serialization fields {#serialization-fields} @@ -330,7 +347,7 @@ serialization by passing in the string names via the `exclude` argument. > #### Example > > ```python -> data = span_predictor.to_disk("/path", exclude=["vocab"]) +> data = span_resolver.to_disk("/path", exclude=["vocab"]) > ``` | Name | Description | From 62ffddd24bc3b452fbe4893d3738d061c6027d3a Mon Sep 17 00:00:00 2001 From: Paul O'Leary McCann Date: Thu, 4 Aug 2022 15:36:40 +0900 Subject: [PATCH 188/188] Update architectures --- website/docs/api/architectures.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/website/docs/api/architectures.md b/website/docs/api/architectures.md index e881864a95c..f671a7d9dec 100644 --- a/website/docs/api/architectures.md +++ b/website/docs/api/architectures.md @@ -923,15 +923,15 @@ plausible [`Candidate`](/api/kb/#candidate) objects. The default `CandidateGenerator` uses the text of a mention to find its potential aliases in the `KnowledgeBase`. Note that this function is case-dependent. -## Coreference Architectures +## Coreference Architectures {#coref-architectures tag="experimental" new="3.4"} A [`CoreferenceResolver`](/api/coref) component identifies tokens that refer to -the same entity. A [`SpanPredictor`](/api/span-predictor) component infers spans +the same entity. A [`SpanResolver`](/api/span-resolver) component infers spans from single tokens. Together these components can be used to reproduce -traditional coreference models. You can also omit the `SpanPredictor` for faster +traditional coreference models. You can also omit the `SpanResolver` for faster performance if working with only token-level clusters is acceptable. -### spacy.Coref.v1 {#Coref} +### spacy.Coref.v1 {#Coref tag="experimental"} > #### Example Config > @@ -966,14 +966,14 @@ The `Coref` model architecture is a Thinc `Model`. | `antecedent_batch_size` | Internal batch size. ~~int~~ | | **CREATES** | The model using the architecture. ~~Model[List[Doc], Floats2d]~~ | -### spacy.SpanPredictor.v1 {#SpanPredictor} +### spacy.SpanResolver.v1 {#SpanResolver tag="experimental"} > #### Example Config > > ```ini > > [model] -> @architectures = "spacy.SpanPredictor.v1" +> @architectures = "spacy.SpanResolver.v1" > hidden_size = 1024 > distance_embedding_size = 64 > conv_channels = 4 @@ -988,7 +988,7 @@ The `Coref` model architecture is a Thinc `Model`. > pooling = {"@layers":"reduce_mean.v1"} > ``` -The `SpanPredictor` model architecture is a Thinc `Model`. +The `SpanResolver` model architecture is a Thinc `Model`. | Name | Description | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |