transitions = new ArrayList<>(2 * n);
+ while (!state.isTerminal()) {
+ final Transition next = nextTransition(gold, goldDependents, state);
+ if (next == null) {
+ throw new IllegalArgumentException(
+ "gold graph has no arc-standard derivation (non-projective): " + gold);
+ }
+ state.apply(next);
+ transitions.add(next);
+ }
+ return transitions;
+ }
+
+ /**
+ * Picks the gold transition for the current configuration, or {@code null} when the
+ * configuration is stuck, which only happens for non-projective input.
+ */
+ private static Transition nextTransition(DependencyGraph gold, int[] goldDependents,
+ ArcStandardState state) {
+ final int s0 = state.stack(0);
+ final int s1 = state.stack(1);
+ if (s1 >= 0 && gold.headOf(s1) == s0) {
+ final Transition leftArc = Transition.leftArc(gold.relationOf(s1));
+ if (state.canApply(leftArc)) {
+ return leftArc;
+ }
+ }
+ if (s0 >= 0 && s1 != ArcStandardState.NONE && gold.headOf(s0) == s1
+ && state.assignedDependents(s0) == goldDependents[s0]) {
+ final Transition rightArc = Transition.rightArc(gold.relationOf(s0));
+ if (state.canApply(rightArc)) {
+ return rightArc;
+ }
+ }
+ if (state.canApply(Transition.SHIFT)) {
+ return Transition.SHIFT;
+ }
+ return null;
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java
new file mode 100644
index 0000000000..afa2646e9a
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.util.Arrays;
+
+/**
+ * The mutable configuration of an arc-standard parse: a stack, a buffer of remaining
+ * tokens, and the arcs assigned so far. The arc-standard transition system is described
+ * in Nivre (2004).
+ *
+ * The stack bottom holds the artificial root, exposed as {@link #ROOT}. Positions that
+ * do not exist, such as the second stack element in the initial configuration, are exposed
+ * as {@link #NONE}. A right arc from the artificial root is only applicable once the buffer
+ * is empty and the root is the only other stack element, which guarantees every completed
+ * parse has exactly one sentence root.
+ *
+ * Instances are confined to a single parse and must not be shared between threads.
+ *
+ * @since 3.0.0
+ */
+public final class ArcStandardState {
+
+ /** The stack value representing the artificial root node. */
+ public static final int ROOT = -1;
+
+ /** The value returned for stack or buffer positions that do not exist. */
+ public static final int NONE = -2;
+
+ private final int tokenCount;
+ private final int[] stack;
+ private final int[] heads;
+ private final String[] relations;
+ private final int[] assignedDependents;
+ private final int[] leftmostDependents;
+ private final int[] rightmostDependents;
+
+ private int top;
+ private int bufferFront;
+
+ /**
+ * Initializes the start configuration for a sentence: the artificial root on the stack
+ * and every token in the buffer.
+ *
+ * @param tokenCount The number of tokens in the sentence. Must be greater than zero.
+ * @throws IllegalArgumentException Thrown if {@code tokenCount} is not positive.
+ */
+ public ArcStandardState(int tokenCount) {
+ if (tokenCount <= 0) {
+ throw new IllegalArgumentException("tokenCount must be positive: " + tokenCount);
+ }
+ this.tokenCount = tokenCount;
+ this.stack = new int[tokenCount + 1];
+ this.stack[0] = ROOT;
+ this.top = 0;
+ this.bufferFront = 0;
+ this.heads = new int[tokenCount];
+ this.relations = new String[tokenCount];
+ this.assignedDependents = new int[tokenCount];
+ this.leftmostDependents = new int[tokenCount];
+ this.rightmostDependents = new int[tokenCount];
+ Arrays.fill(this.leftmostDependents, NONE);
+ Arrays.fill(this.rightmostDependents, NONE);
+ }
+
+ /**
+ * Deep-copies {@code source}; used only by {@link #copy()}.
+ */
+ private ArcStandardState(ArcStandardState source) {
+ this.tokenCount = source.tokenCount;
+ this.stack = source.stack.clone();
+ this.heads = source.heads.clone();
+ this.relations = source.relations.clone();
+ this.assignedDependents = source.assignedDependents.clone();
+ this.leftmostDependents = source.leftmostDependents.clone();
+ this.rightmostDependents = source.rightmostDependents.clone();
+ this.top = source.top;
+ this.bufferFront = source.bufferFront;
+ }
+
+ /**
+ * Creates an independent copy of this configuration, so alternatives can be advanced
+ * side by side during search.
+ *
+ * @return A copy that can be advanced without affecting this state. Never {@code null}.
+ */
+ public ArcStandardState copy() {
+ return new ArcStandardState(this);
+ }
+
+ /**
+ * @return {@code true} if the buffer is empty and only the artificial root remains on
+ * the stack, so the parse is complete.
+ */
+ public boolean isTerminal() {
+ return bufferFront == tokenCount && top == 0;
+ }
+
+ /**
+ * Checks whether a transition may be applied in the current configuration.
+ *
+ * @param transition The transition to check. Must not be {@code null}.
+ * @return {@code true} if {@link #apply(Transition)} would succeed.
+ * @throws IllegalArgumentException Thrown if {@code transition} is {@code null}.
+ */
+ public boolean canApply(Transition transition) {
+ if (transition == null) {
+ throw new IllegalArgumentException("transition must not be null");
+ }
+ return switch (transition.type()) {
+ case SHIFT -> bufferFront < tokenCount;
+ case LEFT_ARC -> top >= 2;
+ case RIGHT_ARC -> top >= 2 || (top == 1 && bufferFront == tokenCount);
+ };
+ }
+
+ /**
+ * Applies a transition, updating stack, buffer, and arcs.
+ *
+ * @param transition The transition to apply. Must not be {@code null} and must be
+ * applicable per {@link #canApply(Transition)}.
+ * @throws IllegalArgumentException Thrown if the transition is {@code null} or not
+ * applicable in the current configuration.
+ */
+ public void apply(Transition transition) {
+ if (!canApply(transition)) {
+ throw new IllegalArgumentException("transition not applicable: " + transition
+ + " in " + this);
+ }
+ switch (transition.type()) {
+ case SHIFT -> {
+ top++;
+ stack[top] = bufferFront++;
+ }
+ case LEFT_ARC -> {
+ final int dependent = stack[top - 1];
+ attach(stack[top], dependent, transition.label());
+ stack[top - 1] = stack[top];
+ top--;
+ }
+ case RIGHT_ARC -> {
+ attach(stack[top - 1], stack[top], transition.label());
+ top--;
+ }
+ default -> throw new IllegalArgumentException("unsupported type: " + transition.type());
+ }
+ }
+
+ /**
+ * Records the arc from {@code head} to {@code dependent} and updates the dependent
+ * bookkeeping of {@code head} when it is a token rather than the artificial root.
+ *
+ * @param head The head token index, or {@link #ROOT} for the artificial root.
+ * @param dependent The zero-based index of the token being attached.
+ * @param relation The relation label of the arc.
+ */
+ private void attach(int head, int dependent, String relation) {
+ heads[dependent] = head;
+ relations[dependent] = relation;
+ if (head >= 0) {
+ assignedDependents[head]++;
+ if (leftmostDependents[head] == NONE || dependent < leftmostDependents[head]) {
+ leftmostDependents[head] = dependent;
+ }
+ if (rightmostDependents[head] == NONE || dependent > rightmostDependents[head]) {
+ rightmostDependents[head] = dependent;
+ }
+ }
+ }
+
+ /**
+ * Retrieves a stack element counted from the top.
+ *
+ * @param fromTop Zero for the top element, one for the element below it, and so on.
+ * Must not be negative.
+ * @return The token index at that position, {@link #ROOT} for the artificial root, or
+ * {@link #NONE} if the position does not exist.
+ * @throws IllegalArgumentException Thrown if {@code fromTop} is negative.
+ */
+ public int stack(int fromTop) {
+ if (fromTop < 0) {
+ throw new IllegalArgumentException("fromTop must not be negative: " + fromTop);
+ }
+ final int position = top - fromTop;
+ return position < 0 ? NONE : stack[position];
+ }
+
+ /**
+ * Retrieves a buffer element counted from the front.
+ *
+ * @param fromFront Zero for the next token to be shifted, one for the token after it,
+ * and so on. Must not be negative.
+ * @return The token index at that position, or {@link #NONE} if the position does not
+ * exist.
+ * @throws IllegalArgumentException Thrown if {@code fromFront} is negative.
+ */
+ public int buffer(int fromFront) {
+ if (fromFront < 0) {
+ throw new IllegalArgumentException("fromFront must not be negative: " + fromFront);
+ }
+ final int position = bufferFront + fromFront;
+ return position >= tokenCount ? NONE : position;
+ }
+
+ /**
+ * @return The number of stack elements including the artificial root.
+ */
+ public int stackSize() {
+ return top + 1;
+ }
+
+ /**
+ * @return The number of tokens still in the buffer.
+ */
+ public int bufferSize() {
+ return tokenCount - bufferFront;
+ }
+
+ /**
+ * Retrieves how many dependents have been attached to a token so far.
+ *
+ * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+ * @return The number of arcs assigned with the token as head.
+ * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+ */
+ public int assignedDependents(int index) {
+ checkTokenIndex(index);
+ return assignedDependents[index];
+ }
+
+ /**
+ * Rejects a token index outside {@code [0, tokenCount)}.
+ *
+ * @param index The zero-based token index to check.
+ * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+ */
+ private void checkTokenIndex(int index) {
+ if (index < 0 || index >= tokenCount) {
+ throw new IllegalArgumentException("token index out of range: " + index);
+ }
+ }
+
+ /**
+ * Retrieves the leftmost dependent attached to a token so far.
+ *
+ * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+ * @return The dependent's token index, or {@link #NONE} when none is attached.
+ * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+ */
+ public int leftmostDependent(int index) {
+ checkTokenIndex(index);
+ return leftmostDependents[index];
+ }
+
+ /**
+ * Retrieves the rightmost dependent attached to a token so far.
+ *
+ * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+ * @return The dependent's token index, or {@link #NONE} when none is attached.
+ * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+ */
+ public int rightmostDependent(int index) {
+ checkTokenIndex(index);
+ return rightmostDependents[index];
+ }
+
+ /**
+ * Retrieves the relation a token was attached under, when it has been attached.
+ *
+ * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+ * @return The relation label, or {@code null} when the token is still unattached.
+ * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+ */
+ public String assignedRelation(int index) {
+ checkTokenIndex(index);
+ return relations[index];
+ }
+
+ /**
+ * Builds the {@link DependencyGraph} of a completed parse.
+ *
+ * @return The parsed graph. Never {@code null}.
+ * @throws IllegalStateException Thrown if the parse is not yet terminal.
+ */
+ public DependencyGraph toGraph() {
+ if (!isTerminal()) {
+ throw new IllegalStateException("parse is not terminal: " + this);
+ }
+ return DependencyGraph.of(heads, relations);
+ }
+
+ @Override
+ public String toString() {
+ return "stackSize=" + stackSize() + ", bufferSize=" + bufferSize()
+ + ", tokenCount=" + tokenCount;
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyAnnotator.java
new file mode 100644
index 0000000000..e7a7d555fa
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyAnnotator.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import opennlp.tools.document.Annotation;
+import opennlp.tools.document.Document;
+import opennlp.tools.document.DocumentAnnotator;
+import opennlp.tools.document.LayerKey;
+import opennlp.tools.document.Layers;
+
+/**
+ * Adapts a {@link DependencyParser} to the document pipeline: reads
+ * {@link Layers#SENTENCES}, {@link Layers#TOKENS}, and {@link Layers#POS_TAGS} and
+ * provides {@link #DEPENDENCIES}, one {@link DependencyArc} per token on the token's
+ * span.
+ *
+ * An arc's {@link DependencyArc#head()} and {@link DependencyArc#dependent()} are
+ * indices into the token layer, following the container's rule that annotations
+ * reference each other by layer and index, never by object identity.
+ *
+ * Each sentence is parsed separately, the way the parser contract expects its input,
+ * so every sentence gets its own tree and its own root arc. The sentence-local indices
+ * the parser returns are shifted by the sentence's first token position, which keeps
+ * every arc's head and dependent a position in the document-wide token layer. Token
+ * spans already refer to the original document text, so anchoring an arc on its
+ * dependent token's span puts the arc in document coordinates.
+ *
+ * @since 3.0.0
+ */
+public class DependencyAnnotator implements DocumentAnnotator {
+
+ /**
+ * Dependency arcs; one annotation per token, aligned with {@link Layers#TOKENS} by
+ * position, anchored on the dependent token's span.
+ */
+ public static final LayerKey DEPENDENCIES =
+ Layers.key("dependencies", DependencyArc.class);
+
+ /** The message prefix of every absent-required-layer rejection in this adapter. */
+ private static final String MISSING_LAYER = "document lacks the required layer ";
+
+ private final DependencyParser parser;
+
+ /**
+ * Initializes the adapter.
+ *
+ * @param parser The dependency parser to delegate to. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code parser} is {@code null}.
+ */
+ public DependencyAnnotator(DependencyParser parser) {
+ if (parser == null) {
+ throw new IllegalArgumentException("parser must not be null");
+ }
+ this.parser = parser;
+ }
+
+ /**
+ * Parses the document sentence by sentence and adds the {@link #DEPENDENCIES} layer.
+ *
+ * For every sentence, the tokens whose spans lie inside the sentence span are
+ * passed to the parser with their tags as one sequence, and the resulting
+ * sentence-local arcs are shifted by the sentence's first token position. Arcs are
+ * emitted in token order, so the new layer is aligned with {@link Layers#TOKENS} by
+ * position, and each arc annotation reuses the span of its dependent token. The
+ * required layers must be present, but they may be empty: a document without
+ * sentences or tokens yields a present-but-empty dependency layer, and a sentence
+ * containing no tokens contributes no arcs.
+ *
+ * The sentence and token layers must both be in text order: the walk assigns each
+ * sentence the contiguous run of tokens its span encloses, so a token that appears
+ * before its sentence in the layer, or a token overlapping a sentence boundary, is
+ * reported as lying outside every sentence rather than being silently attached to a
+ * neighboring sentence.
+ *
+ * @param document The document to annotate. Must not be {@code null} and must carry
+ * the {@link Layers#SENTENCES} and {@link Layers#TOKENS} layers, in
+ * text order, and a {@link Layers#POS_TAGS} layer with exactly one
+ * tag per token, with every token lying inside a sentence.
+ * @return A new {@link Document} with the {@link #DEPENDENCIES} layer added. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
+ * sentence layer, the token layer, or the tag layer is absent, the tag layer
+ * does not have exactly one tag per token, a token lies outside every
+ * sentence under the text-order walk, or the parser returns a graph whose
+ * size differs from its sentence's token count.
+ */
+ @Override
+ public Document annotate(Document document) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ if (!document.layers().contains(Layers.SENTENCES)) {
+ throw new IllegalArgumentException(MISSING_LAYER + Layers.SENTENCES);
+ }
+ if (!document.layers().contains(Layers.TOKENS)) {
+ throw new IllegalArgumentException(MISSING_LAYER + Layers.TOKENS);
+ }
+ if (!document.layers().contains(Layers.POS_TAGS)) {
+ throw new IllegalArgumentException(MISSING_LAYER + Layers.POS_TAGS);
+ }
+ final List> sentences = document.get(Layers.SENTENCES);
+ final List> tokens = document.get(Layers.TOKENS);
+ final List> tags = document.get(Layers.POS_TAGS);
+ if (tags.size() != tokens.size()) {
+ throw new IllegalArgumentException("document needs aligned "
+ + Layers.TOKENS + " and " + Layers.POS_TAGS + " layers");
+ }
+ final List> arcs = new ArrayList<>(tokens.size());
+ // Walk the token layer once: both layers are in text order, so each sentence
+ // consumes the contiguous run of tokens whose spans it encloses.
+ int next = 0;
+ for (final Annotation sentence : sentences) {
+ final int first = next;
+ while (next < tokens.size()
+ && tokens.get(next).span().getStart() >= sentence.span().getStart()
+ && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
+ next++;
+ }
+ final int count = next - first;
+ if (count == 0) {
+ continue;
+ }
+ final String[] words = new String[count];
+ final String[] posTags = new String[count];
+ for (int i = 0; i < count; i++) {
+ words[i] = tokens.get(first + i).value();
+ posTags[i] = tags.get(first + i).value();
+ }
+ final DependencyGraph graph = parser.parse(words, posTags);
+ if (graph.size() != count) {
+ throw new IllegalArgumentException("parser returned a graph over " + graph.size()
+ + " tokens for a sentence of " + count);
+ }
+ // The parser indexes within the sentence; shifting by the sentence's first token
+ // position turns every head and dependent into a document-wide token index.
+ for (final DependencyArc arc : graph.arcs()) {
+ final int head = arc.head() == DependencyArc.ROOT_HEAD
+ ? DependencyArc.ROOT_HEAD : arc.head() + first;
+ arcs.add(new Annotation<>(tokens.get(first + arc.dependent()).span(),
+ new DependencyArc(head, arc.dependent() + first, arc.relation())));
+ }
+ }
+ if (next != tokens.size()) {
+ throw new IllegalArgumentException("token at " + tokens.get(next).span()
+ + " lies outside every sentence");
+ }
+ return document.with(DEPENDENCIES, arcs);
+ }
+
+ @Override
+ public Set> requires() {
+ return Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS);
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(DEPENDENCIES);
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
new file mode 100644
index 0000000000..bcd7ab732d
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Generates the classification features for one arc-standard configuration: words and
+ * tags of the topmost stack and frontmost buffer positions, their pairings, the partial
+ * structure built so far (tags and relations of the leftmost and rightmost dependents,
+ * valency counts), and a bucketed distance between stack top and buffer front.
+ *
+ * Instances hold no state and are safe to share between threads.
+ *
+ * @since 3.0.0
+ */
+public class DependencyContextGenerator {
+
+ private static final String ROOT_VALUE = "*ROOT*";
+ private static final String NONE_VALUE = "*NULL*";
+
+ /** Separates a word from the tag of the same position within one feature. */
+ private static final char WORD_TAG_SEPARATOR = '/';
+
+ /** Separates the parts of a feature combining several positions. */
+ private static final char POSITION_SEPARATOR = '|';
+
+ /** The number of features {@link #getContext(ArcStandardState, String[], String[])} emits. */
+ private static final int FEATURE_COUNT = 37;
+
+ /** Valency counts at or above this bound share one feature value. */
+ private static final int MAX_VALENCY = 3;
+
+ /** Distances at or above this bound share the {@link #LONG_DISTANCE} feature value. */
+ private static final int MAX_DISTANCE = 4;
+
+ /** The feature value standing for every distance of {@link #MAX_DISTANCE} or more. */
+ private static final String LONG_DISTANCE = "4+";
+
+ /**
+ * Generates the features of the current configuration.
+ *
+ * @param state The configuration to describe. Must not be {@code null}.
+ * @param tokens The sentence tokens. Must not be {@code null}.
+ * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+ * {@code null}.
+ * @return The feature strings. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if any parameter is {@code null}.
+ */
+ public String[] getContext(ArcStandardState state, String[] tokens, String[] tags) {
+ if (state == null || tokens == null || tags == null) {
+ throw new IllegalArgumentException("state, tokens and tags must not be null");
+ }
+ final int s0 = state.stack(0);
+ final int s1 = state.stack(1);
+ final int s2 = state.stack(2);
+ final int b0 = state.buffer(0);
+ final int b1 = state.buffer(1);
+ final int b2 = state.buffer(2);
+
+ final String s0w = word(tokens, s0);
+ final String s0t = tag(tags, s0);
+ final String s1w = word(tokens, s1);
+ final String s1t = tag(tags, s1);
+ final String s2t = tag(tags, s2);
+ final String b0w = word(tokens, b0);
+ final String b0t = tag(tags, b0);
+ final String b1w = word(tokens, b1);
+ final String b1t = tag(tags, b1);
+ final String b2t = tag(tags, b2);
+
+ final String s0lct = dependentTag(state, tags, s0, true);
+ final String s0rct = dependentTag(state, tags, s0, false);
+ final String s1lct = dependentTag(state, tags, s1, true);
+ final String s1rct = dependentTag(state, tags, s1, false);
+ final String s0lcl = dependentRelation(state, s0, true);
+ final String s0rcl = dependentRelation(state, s0, false);
+ final String s1rcl = dependentRelation(state, s1, false);
+
+ final List features = new ArrayList<>(FEATURE_COUNT);
+ features.add("s0w=" + s0w);
+ features.add("s0t=" + s0t);
+ features.add("s1w=" + s1w);
+ features.add("s1t=" + s1t);
+ features.add("s2t=" + s2t);
+ features.add("b0w=" + b0w);
+ features.add("b0t=" + b0t);
+ features.add("b1w=" + b1w);
+ features.add("b1t=" + b1t);
+ features.add("b2t=" + b2t);
+ features.add("s0wt=" + s0w + WORD_TAG_SEPARATOR + s0t);
+ features.add("s1wt=" + s1w + WORD_TAG_SEPARATOR + s1t);
+ features.add("b0wt=" + b0w + WORD_TAG_SEPARATOR + b0t);
+ features.add("s0w,b0w=" + s0w + POSITION_SEPARATOR + b0w);
+ features.add("s0t,b0t=" + s0t + POSITION_SEPARATOR + b0t);
+ features.add("s0w,b0t=" + s0w + POSITION_SEPARATOR + b0t);
+ features.add("s0t,b0w=" + s0t + POSITION_SEPARATOR + b0w);
+ features.add("s0wt,b0t=" + s0w + WORD_TAG_SEPARATOR + s0t + POSITION_SEPARATOR + b0t);
+ features.add("s1t,s0t=" + s1t + POSITION_SEPARATOR + s0t);
+ features.add("s1t,s0w=" + s1t + POSITION_SEPARATOR + s0w);
+ features.add("s1w,s0t=" + s1w + POSITION_SEPARATOR + s0t);
+ features.add("s1t,s0t,b0t=" + s1t + POSITION_SEPARATOR + s0t + POSITION_SEPARATOR + b0t);
+ features.add("s0t,b0t,b1t=" + s0t + POSITION_SEPARATOR + b0t + POSITION_SEPARATOR + b1t);
+ features.add("s2t,s1t,s0t=" + s2t + POSITION_SEPARATOR + s1t + POSITION_SEPARATOR + s0t);
+ features.add("s0lct=" + s0lct);
+ features.add("s0rct=" + s0rct);
+ features.add("s1lct=" + s1lct);
+ features.add("s1rct=" + s1rct);
+ features.add("s0lcl=" + s0lcl);
+ features.add("s0rcl=" + s0rcl);
+ features.add("s1rcl=" + s1rcl);
+ features.add("s1t,s1rct,s0t=" + s1t + POSITION_SEPARATOR + s1rct + POSITION_SEPARATOR + s0t);
+ features.add("s0t,s0lct,b0t=" + s0t + POSITION_SEPARATOR + s0lct + POSITION_SEPARATOR + b0t);
+ features.add("s0deps=" + dependents(state, s0));
+ features.add("s1deps=" + dependents(state, s1));
+ final String distance = distance(s0, b0);
+ features.add("dist=" + distance);
+ features.add("dist,s0t,b0t=" + distance + POSITION_SEPARATOR + s0t + POSITION_SEPARATOR + b0t);
+ return features.toArray(new String[0]);
+ }
+
+ /** The word at a position, or the marker value for the root and absent positions. */
+ private String word(String[] tokens, int index) {
+ if (index == ArcStandardState.ROOT) {
+ return ROOT_VALUE;
+ }
+ return index == ArcStandardState.NONE ? NONE_VALUE : tokens[index];
+ }
+
+ /** The tag at a position, or the marker value for the root and absent positions. */
+ private String tag(String[] tags, int index) {
+ if (index == ArcStandardState.ROOT) {
+ return ROOT_VALUE;
+ }
+ return index == ArcStandardState.NONE ? NONE_VALUE : tags[index];
+ }
+
+ /** The tag of a token's leftmost or rightmost dependent attached so far. */
+ private String dependentTag(ArcStandardState state, String[] tags, int index,
+ boolean leftmost) {
+ if (index < 0) {
+ return NONE_VALUE;
+ }
+ final int dependent =
+ leftmost ? state.leftmostDependent(index) : state.rightmostDependent(index);
+ return tag(tags, dependent);
+ }
+
+ /** The relation of a token's leftmost or rightmost dependent attached so far. */
+ private String dependentRelation(ArcStandardState state, int index,
+ boolean leftmost) {
+ if (index < 0) {
+ return NONE_VALUE;
+ }
+ final int dependent =
+ leftmost ? state.leftmostDependent(index) : state.rightmostDependent(index);
+ if (dependent < 0) {
+ return NONE_VALUE;
+ }
+ final String relation = state.assignedRelation(dependent);
+ return relation == null ? NONE_VALUE : relation;
+ }
+
+ /** A token's dependent count so far, capped at {@link #MAX_VALENCY}. */
+ private String dependents(ArcStandardState state, int index) {
+ return index < 0 ? NONE_VALUE
+ : Integer.toString(Math.min(state.assignedDependents(index), MAX_VALENCY));
+ }
+
+ /** The bucketed distance between stack top and buffer front, capped at {@link #MAX_DISTANCE}. */
+ private String distance(int s0, int b0) {
+ if (s0 < 0 || b0 < 0) {
+ return NONE_VALUE;
+ }
+ final int distance = b0 - s0;
+ return distance >= MAX_DISTANCE ? LONG_DISTANCE : Integer.toString(distance);
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java
new file mode 100644
index 0000000000..c71c538c5f
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import opennlp.tools.util.eval.Evaluator;
+import opennlp.tools.util.eval.Mean;
+
+/**
+ * Measures the quality of a {@link DependencyParser} against gold
+ * {@link DependencySample samples} with the two standard scores: the unlabeled attachment
+ * score (UAS, the fraction of tokens with the correct head) and the labeled attachment
+ * score (LAS, the fraction of tokens with the correct head and relation label).
+ *
+ * @since 3.0.0
+ */
+public class DependencyEvaluator extends Evaluator {
+
+ private final DependencyParser parser;
+ private final Mean uas = new Mean();
+ private final Mean las = new Mean();
+
+ /**
+ * Initializes a {@link DependencyEvaluator}.
+ *
+ * @param parser The parser to evaluate. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code parser} is {@code null}.
+ */
+ public DependencyEvaluator(DependencyParser parser) {
+ if (parser == null) {
+ throw new IllegalArgumentException("parser must not be null");
+ }
+ this.parser = parser;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * The returned sample carries the predicted graph over the reference tokens, and
+ * every token of the reference contributes to both scores.
+ */
+ @Override
+ protected DependencySample processSample(DependencySample reference) {
+ final DependencyGraph gold = reference.getGraph();
+ final DependencyGraph predicted = parser.parse(reference.getTokens(), reference.getTags());
+ for (int i = 0; i < gold.size(); i++) {
+ final boolean headMatches = gold.headOf(i) == predicted.headOf(i);
+ uas.add(headMatches ? 1 : 0);
+ las.add(headMatches && gold.relationOf(i).equals(predicted.relationOf(i)) ? 1 : 0);
+ }
+ return new DependencySample(reference.getTokens(), reference.getTags(), predicted);
+ }
+
+ /**
+ * @return The unlabeled attachment score over all evaluated tokens.
+ */
+ public double getUas() {
+ return uas.mean();
+ }
+
+ /**
+ * @return The labeled attachment score over all evaluated tokens.
+ */
+ public double getLas() {
+ return las.mean();
+ }
+
+ /**
+ * @return The number of tokens scored so far.
+ */
+ public long getWordCount() {
+ return uas.count();
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java
new file mode 100644
index 0000000000..87fdf27300
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.model.Event;
+import opennlp.tools.util.ObjectStream;
+
+/**
+ * Turns {@link DependencySample samples} into training {@link Event events}: for each
+ * sample the {@link ArcStandardOracle} derives the gold transitions, and every transition
+ * becomes one event pairing the configuration features with the encoded transition.
+ *
+ * Samples whose graph has no arc-standard derivation, that is non-projective trees,
+ * are skipped and counted; the count is logged once the stream is exhausted.
+ */
+class DependencyEventStream implements ObjectStream {
+
+ private static final Logger logger = LoggerFactory.getLogger(DependencyEventStream.class);
+
+ private final ObjectStream samples;
+ private final DependencyContextGenerator contextGenerator;
+ private final Deque pending = new ArrayDeque<>();
+
+ private int skipped;
+
+ /**
+ * Initializes the stream.
+ *
+ * @param samples The samples to convert. Must not be {@code null}.
+ * @param contextGenerator The feature generator. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if any parameter is {@code null}.
+ */
+ DependencyEventStream(ObjectStream samples,
+ DependencyContextGenerator contextGenerator) {
+ if (samples == null || contextGenerator == null) {
+ throw new IllegalArgumentException("samples and contextGenerator must not be null");
+ }
+ this.samples = samples;
+ this.contextGenerator = contextGenerator;
+ }
+
+ @Override
+ public Event read() throws IOException {
+ while (pending.isEmpty()) {
+ final DependencySample sample = samples.read();
+ if (sample == null) {
+ if (skipped > 0) {
+ logger.warn("Skipped {} non-projective sample(s) without an arc-standard derivation.",
+ skipped);
+ skipped = 0;
+ }
+ return null;
+ }
+ final List transitions;
+ try {
+ transitions = ArcStandardOracle.transitions(sample.getGraph());
+ } catch (IllegalArgumentException e) {
+ skipped++;
+ continue;
+ }
+ final ArcStandardState state = new ArcStandardState(sample.getGraph().size());
+ final String[] tokens = sample.getTokens();
+ final String[] tags = sample.getTags();
+ for (final Transition transition : transitions) {
+ pending.add(new Event(transition.encode(),
+ contextGenerator.getContext(state, tokens, tags)));
+ state.apply(transition);
+ }
+ }
+ return pending.poll();
+ }
+
+ @Override
+ public void reset() throws IOException, UnsupportedOperationException {
+ samples.reset();
+ pending.clear();
+ skipped = 0;
+ }
+
+ @Override
+ public void close() throws IOException {
+ samples.close();
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java
new file mode 100644
index 0000000000..8f2c608eeb
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import java.util.Map;
+
+import opennlp.tools.ml.model.AbstractModel;
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * The persisted form of a trained {@link DependencyParserME}: the transition
+ * classification model plus the standard model manifest, serialized and loaded through
+ * the same {@link BaseModel} machinery as every other tool model.
+ *
+ * @see DependencyParserME
+ * @since 3.0.0
+ */
+public class DependencyModel extends BaseModel {
+
+ private static final long serialVersionUID = -2928968185269611443L;
+
+ private static final String COMPONENT_NAME = "DependencyParserME";
+ static final String PARSER_MODEL_ENTRY_NAME = "depparse.model";
+
+ /**
+ * Initializes a {@link DependencyModel} from a trained transition model.
+ *
+ * @param languageCode The ISO language code of the training data. Must not be
+ * {@code null}.
+ * @param parserModel The transition classification model. Must not be {@code null}.
+ * @param manifestInfoEntries Additional entries for the manifest, or {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code parserModel} is {@code null}.
+ */
+ public DependencyModel(String languageCode, MaxentModel parserModel,
+ Map manifestInfoEntries) {
+ super(COMPONENT_NAME, languageCode, manifestInfoEntries);
+ if (parserModel == null) {
+ throw new IllegalArgumentException("parserModel must not be null");
+ }
+ artifactMap.put(PARSER_MODEL_ENTRY_NAME, parserModel);
+ checkArtifactMap();
+ }
+
+ /**
+ * Initializes a {@link DependencyModel} from a serialized model.
+ *
+ * @param in The stream to read the model from. Must not be {@code null}.
+ * @throws IOException Thrown if reading fails or the content is not a valid model.
+ */
+ public DependencyModel(InputStream in) throws IOException {
+ super(COMPONENT_NAME, in);
+ }
+
+ /**
+ * Initializes a {@link DependencyModel} from a serialized model file.
+ *
+ * @param modelFile The file to read the model from. Must not be {@code null}.
+ * @throws IOException Thrown if reading fails or the content is not a valid model.
+ */
+ public DependencyModel(File modelFile) throws IOException {
+ super(COMPONENT_NAME, modelFile);
+ }
+
+ /**
+ * Initializes a {@link DependencyModel} from a serialized model file.
+ *
+ * @param modelPath The path to read the model from. Must not be {@code null}.
+ * @throws IOException Thrown if reading fails or the content is not a valid model.
+ */
+ public DependencyModel(Path modelPath) throws IOException {
+ super(COMPONENT_NAME, modelPath);
+ }
+
+ @Override
+ protected void validateArtifactMap() throws InvalidFormatException {
+ super.validateArtifactMap();
+ if (!(artifactMap.get(PARSER_MODEL_ENTRY_NAME) instanceof AbstractModel)) {
+ throw new InvalidFormatException("The " + PARSER_MODEL_ENTRY_NAME
+ + " artifact is missing or not a supported transition model.");
+ }
+ }
+
+ /**
+ * @return The transition classification model. Never {@code null}.
+ */
+ public MaxentModel getParserModel() {
+ return (MaxentModel) artifactMap.get(PARSER_MODEL_ENTRY_NAME);
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java
new file mode 100644
index 0000000000..c565e61724
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import opennlp.tools.ml.EventTrainer;
+import opennlp.tools.ml.TrainerFactory;
+import opennlp.tools.ml.TrainerFactory.TrainerType;
+import opennlp.tools.ml.model.Event;
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.TrainingParameters;
+
+/**
+ * A greedy transition-based {@link DependencyParser}: a maximum entropy classifier picks
+ * the next arc-standard {@link Transition} for each configuration until the parse is
+ * complete, always taking the highest scoring transition that is applicable.
+ *
+ * The parser holds an immutable model and no per-parse state, so one instance can be
+ * shared between threads.
+ *
+ * @see DependencyParser
+ * @since 3.0.0
+ */
+public class DependencyParserME implements DependencyParser {
+
+ private final MaxentModel model;
+ private final DependencyContextGenerator contextGenerator;
+ private final Transition[] transitions;
+
+ /**
+ * Initializes a {@link DependencyParserME} from a {@link DependencyModel}.
+ *
+ * @param model The model to parse with. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code model} is {@code null} or an
+ * outcome of the model does not decode to a transition.
+ */
+ public DependencyParserME(DependencyModel model) {
+ if (model == null) {
+ throw new IllegalArgumentException("model must not be null");
+ }
+ this.model = model.getParserModel();
+ this.contextGenerator = new DependencyContextGenerator();
+ this.transitions = decodeOutcomes(this.model);
+ }
+
+ /**
+ * Initializes a {@link DependencyParserME} with a raw transition model.
+ *
+ * @param model The transition classification model. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code model} is {@code null} or an
+ * outcome of the model does not decode to a transition.
+ */
+ public DependencyParserME(MaxentModel model) {
+ if (model == null) {
+ throw new IllegalArgumentException("model must not be null");
+ }
+ this.model = model;
+ this.contextGenerator = new DependencyContextGenerator();
+ this.transitions = decodeOutcomes(model);
+ }
+
+ /**
+ * Decodes the outcome inventory once, so that decoding a sentence indexes it instead
+ * of parsing an outcome string per configuration and outcome.
+ *
+ * @param model The transition classification model.
+ * @return The transitions by outcome index. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if an outcome does not decode to a
+ * transition, which means the model is not a dependency parser model.
+ */
+ private static Transition[] decodeOutcomes(MaxentModel model) {
+ final Transition[] decoded = new Transition[model.getNumOutcomes()];
+ for (int i = 0; i < decoded.length; i++) {
+ final String outcome = model.getOutcome(i);
+ try {
+ decoded[i] = Transition.decode(outcome);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("model outcome is not a transition: " + outcome, e);
+ }
+ }
+ return decoded;
+ }
+
+ @Override
+ public DependencyGraph parse(String[] tokens, String[] tags) {
+ if (tokens == null || tags == null) {
+ throw new IllegalArgumentException("tokens and tags must not be null");
+ }
+ if (tokens.length == 0) {
+ throw new IllegalArgumentException("tokens must not be empty");
+ }
+ if (tokens.length != tags.length) {
+ throw new IllegalArgumentException("tokens and tags must have the same length: "
+ + tokens.length + " != " + tags.length);
+ }
+ final ArcStandardState state = new ArcStandardState(tokens.length);
+ while (!state.isTerminal()) {
+ state.apply(bestApplicable(state, tokens, tags));
+ }
+ return state.toGraph();
+ }
+
+ /**
+ * Scores all outcomes for the current configuration and picks the best transition that
+ * is applicable; inapplicable outcomes are passed over regardless of score.
+ */
+ private Transition bestApplicable(ArcStandardState state, String[] tokens, String[] tags) {
+ final double[] probabilities = model.eval(contextGenerator.getContext(state, tokens, tags));
+ Transition best = null;
+ double bestProbability = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < probabilities.length; i++) {
+ if (probabilities[i] <= bestProbability) {
+ continue;
+ }
+ if (state.canApply(transitions[i])) {
+ best = transitions[i];
+ bestProbability = probabilities[i];
+ }
+ }
+ if (best == null) {
+ throw new IllegalStateException(
+ "no applicable transition among the model outcomes in " + state);
+ }
+ return best;
+ }
+
+ /**
+ * Trains a greedy arc-standard parser model from dependency samples.
+ *
+ * Non-projective samples have no arc-standard derivation and are skipped during
+ * event generation.
+ *
+ * @param languageCode The ISO language code of the training data. Must not be
+ * {@code null}.
+ * @param samples The training samples. Must not be {@code null}.
+ * @param parameters The {@link TrainingParameters}. Must not be {@code null} and must
+ * select an event model trainer.
+ * @return A trained {@link DependencyModel}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if a parameter is {@code null} or the
+ * configured trainer is not an event model trainer.
+ */
+ public static DependencyModel train(String languageCode,
+ ObjectStream samples, TrainingParameters parameters)
+ throws IOException {
+ if (languageCode == null || samples == null || parameters == null) {
+ throw new IllegalArgumentException(
+ "languageCode, samples and parameters must not be null");
+ }
+ final TrainerType trainerType = TrainerFactory.getTrainerType(parameters);
+ if (!TrainerType.EVENT_MODEL_TRAINER.equals(trainerType)) {
+ throw new IllegalArgumentException("Trainer type is not supported: " + trainerType);
+ }
+ final Map manifestInfoEntries = new HashMap<>();
+ final EventTrainer trainer =
+ TrainerFactory.getEventTrainer(parameters, manifestInfoEntries);
+ final ObjectStream events =
+ new DependencyEventStream(samples, new DependencyContextGenerator());
+ return new DependencyModel(languageCode, trainer.train(events), manifestInfoEntries);
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardContext.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardContext.java
new file mode 100644
index 0000000000..29fb8e4c87
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardContext.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+/**
+ * The feature template of the feedforward parser: a fixed set of configuration
+ * positions whose words, tags, and arc labels are embedded and concatenated into the
+ * network input, following
+ * Chen and Manning (2014).
+ *
+ * Positions: the top three stack and buffer items; the leftmost and rightmost
+ * dependents of the top two stack items; and the leftmost dependent of the leftmost
+ * dependent and rightmost dependent of the rightmost dependent of the top two stack
+ * items, capturing second-order structure. Words and tags are read for all positions,
+ * labels only for the dependent positions, whose relations are already assigned.
+ */
+final class FeedforwardContext {
+
+ /** The number of positions whose word and tag are embedded. */
+ static final int POSITIONS = 14;
+
+ /** The number of dependent positions whose arc label is embedded. */
+ static final int LABEL_POSITIONS = 8;
+
+ /** The index of the first dependent position; the stack and buffer items precede it. */
+ private static final int FIRST_DEPENDENT_POSITION = 6;
+
+ private FeedforwardContext() {
+ // This class only exposes the static feature template and is never instantiated.
+ }
+
+ /**
+ * Extracts the symbolic features of a configuration: {@link #POSITIONS} words, then
+ * {@link #POSITIONS} tags, then {@link #LABEL_POSITIONS} labels; absent positions
+ * yield {@code null} entries, which the vocabulary maps to its padding symbol.
+ *
+ * @param state The configuration to describe. Must not be {@code null}.
+ * @param tokens The sentence tokens. Must not be {@code null}.
+ * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+ * {@code null}.
+ * @return The symbolic features, in the order described above. Never {@code null}.
+ */
+ static String[] extract(ArcStandardState state, String[] tokens, String[] tags) {
+ final int s0 = state.stack(0);
+ final int s1 = state.stack(1);
+ final int[] positions = {
+ s0, s1, state.stack(2),
+ state.buffer(0), state.buffer(1), state.buffer(2),
+ leftmost(state, s0), rightmost(state, s0),
+ leftmost(state, s1), rightmost(state, s1),
+ leftmost(state, leftmost(state, s0)), rightmost(state, rightmost(state, s0)),
+ leftmost(state, leftmost(state, s1)), rightmost(state, rightmost(state, s1))
+ };
+ final String[] features = new String[2 * POSITIONS + LABEL_POSITIONS];
+ for (int i = 0; i < POSITIONS; i++) {
+ features[i] = symbol(tokens, positions[i]);
+ features[POSITIONS + i] = symbol(tags, positions[i]);
+ }
+ for (int i = 0; i < LABEL_POSITIONS; i++) {
+ final int position = positions[FIRST_DEPENDENT_POSITION + i];
+ features[2 * POSITIONS + i] =
+ position >= 0 ? state.assignedRelation(position) : null;
+ }
+ return features;
+ }
+
+ /** The leftmost dependent of a position, or the absence marker for absent positions. */
+ private static int leftmost(ArcStandardState state, int index) {
+ return index >= 0 ? state.leftmostDependent(index) : ArcStandardState.NONE;
+ }
+
+ /** The rightmost dependent of a position, or the absence marker for absent positions. */
+ private static int rightmost(ArcStandardState state, int index) {
+ return index >= 0 ? state.rightmostDependent(index) : ArcStandardState.NONE;
+ }
+
+ /** The value at a position: the root symbol for the root, {@code null} when absent. */
+ private static String symbol(String[] values, int index) {
+ if (index == ArcStandardState.ROOT) {
+ return FeedforwardDependencyModel.ROOT_SYMBOL;
+ }
+ return index == ArcStandardState.NONE ? null : values[index];
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java
new file mode 100644
index 0000000000..dca28791b7
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java
@@ -0,0 +1,602 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReferenceArray;
+
+import opennlp.tools.util.StringUtil;
+
+/**
+ * The weights of the feedforward transition parser: embeddings for words, tags, and arc
+ * labels, one hidden layer with cube activation, and a transition output layer, stored
+ * in a plain versioned binary format with no serialization framework involved. The
+ * architecture follows
+ * Chen and Manning (2014).
+ *
+ * This is the pure-Java neural tier: the network is executed with ordinary array
+ * arithmetic, so parsing needs no native runtime, and the same class scores
+ * configurations for training and decoding. Unknown words fall back to a learned
+ * unknown symbol; words are matched case-insensitively after
+ * {@link #normalize(String) normalization}.
+ *
+ * An instance is immutable and safe to share between threads once it has been handed
+ * to a caller. {@link FeedforwardDependencyTrainer} fills the weights while building a
+ * model and before that model escapes, and
+ * {@link FeedforwardDependencyTrainer#refine refine} trains a copy rather than the model
+ * it is given, so no model a caller holds ever changes underneath it.
+ *
+ * @see FeedforwardDependencyParser
+ * @see FeedforwardDependencyTrainer
+ * @since 3.0.0
+ */
+public class FeedforwardDependencyModel {
+
+ private static final String MAGIC = "ONLP-FFDP-1";
+
+ /** U+03A3, GREEK CAPITAL LETTER SIGMA, the one code point with a contextual lowering. */
+ private static final int GREEK_CAPITAL_SIGMA = 0x03A3;
+
+ /** U+03C2, GREEK SMALL LETTER FINAL SIGMA, the word-final lowering of the capital. */
+ private static final char GREEK_SMALL_FINAL_SIGMA = '\u03C2';
+
+ /** The vocabulary key of every word, tag, or label the model has no embedding row for. */
+ static final String UNKNOWN = "*UNK*";
+
+ /** The vocabulary key of a template position that does not exist in a configuration. */
+ static final String ABSENT = "*NULL*";
+
+ /** The vocabulary key of the artificial root node. */
+ static final String ROOT_SYMBOL = "*ROOT*";
+
+ /** The prefix marking a vocabulary key as one of the special symbols above. */
+ static final String SPECIAL_SYMBOL_PREFIX = "*";
+
+ /** The lazy scoring cache; {@code null} until {@link #enableScoringCache()}. */
+ private volatile ContributionCache cache;
+
+ private final Map wordIds;
+ private final Map tagIds;
+ private final Map labelIds;
+ private final String[] transitions;
+
+ private final int embeddingSize;
+ private final float[][] embeddings;
+ private final float[][] hiddenWeights;
+ private final float[] hiddenBias;
+ private final float[][] outputWeights;
+ private final float[] outputBias;
+
+ FeedforwardDependencyModel(Map wordIds, Map tagIds,
+ Map labelIds, String[] transitions, int embeddingSize,
+ float[][] embeddings, float[][] hiddenWeights, float[] hiddenBias,
+ float[][] outputWeights, float[] outputBias) {
+ this.wordIds = Map.copyOf(wordIds);
+ this.tagIds = Map.copyOf(tagIds);
+ this.labelIds = Map.copyOf(labelIds);
+ this.transitions = transitions;
+ this.embeddingSize = embeddingSize;
+ this.embeddings = embeddings;
+ this.hiddenWeights = hiddenWeights;
+ this.hiddenBias = hiddenBias;
+ this.outputWeights = outputWeights;
+ this.outputBias = outputBias;
+ }
+
+ /**
+ * Scores every transition for a configuration described by embedding row indices.
+ *
+ * @param features The embedding rows of the configuration, as produced by
+ * {@link #featureIds(String[])}. Must not be {@code null}.
+ * @return One unnormalized score per transition, indexed like
+ * {@link #transitions()}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code features} is {@code null}.
+ */
+ public double[] score(int[] features) {
+ if (features == null) {
+ throw new IllegalArgumentException("features must not be null");
+ }
+ final int hidden = hiddenBias.length;
+ final double[] h = new double[hidden];
+ for (int j = 0; j < hidden; j++) {
+ h[j] = hiddenBias[j];
+ }
+ final ContributionCache cache = this.cache;
+ for (int f = 0; f < features.length; f++) {
+ final int row = features[f];
+ final float[] contribution = cache == null ? null : cache.contribution(this, f, row);
+ if (contribution != null) {
+ for (int j = 0; j < hidden; j++) {
+ h[j] += contribution[j];
+ }
+ } else {
+ final float[] embedding = embeddings[row];
+ final int offset = f * embeddingSize;
+ for (int j = 0; j < hidden; j++) {
+ final float[] weights = hiddenWeights[j];
+ double sum = 0.0;
+ for (int d = 0; d < embeddingSize; d++) {
+ sum += weights[offset + d] * embedding[d];
+ }
+ h[j] += sum;
+ }
+ }
+ }
+ for (int j = 0; j < hidden; j++) {
+ h[j] = h[j] * h[j] * h[j];
+ }
+ final double[] scores = new double[transitions.length];
+ for (int o = 0; o < scores.length; o++) {
+ final float[] row = outputWeights[o];
+ double sum = outputBias[o];
+ for (int j = 0; j < hidden; j++) {
+ sum += row[j] * h[j];
+ }
+ scores[o] = sum;
+ }
+ return scores;
+ }
+
+ /**
+ * Turns on the scoring cache: the hidden-layer contribution of a (template position,
+ * embedding row) pair is a fixed vector while the weights do not change, so it is
+ * computed once on first sight and afterwards added instead of being re-derived from
+ * the embedding on every configuration.
+ *
+ * Cached contributions are rounded to floats once, so scores may differ from the
+ * uncached path in the last bits. The cache is bounded, safe for concurrent readers,
+ * and only valid on a model whose weights no longer change: training and refinement
+ * work on uncached copies, and {@link #copy()} never carries a cache over.
+ */
+ void enableScoringCache() {
+ if (cache == null) {
+ cache = new ContributionCache(2 * FeedforwardContext.POSITIONS
+ + FeedforwardContext.LABEL_POSITIONS, embeddings.length);
+ }
+ }
+
+ /**
+ * The bounded lazy contribution cache behind {@link #enableScoringCache()}: one
+ * slot per (template position, embedding row) pair, filled on first use. Filling is
+ * idempotent, so concurrent readers may compute a contribution twice but never see
+ * a partial one, and a shared budget bounds the total memory; pairs beyond the
+ * budget simply keep the direct path.
+ */
+ private static final class ContributionCache {
+
+ /** The most (position, row) pairs the cache will hold. At a hidden size of 400
+ * this bounds the cache near 100 MB; typical models stay far below the cap
+ * because tag and label inventories are small and word usage is Zipf-shaped. */
+ private static final int MAX_PAIRS = 65536;
+
+ private final AtomicReferenceArray[] byPosition;
+ private final AtomicInteger remaining = new AtomicInteger(MAX_PAIRS);
+
+ @SuppressWarnings("unchecked")
+ private ContributionCache(int positions, int rows) {
+ byPosition = new AtomicReferenceArray[positions];
+ for (int f = 0; f < positions; f++) {
+ byPosition[f] = new AtomicReferenceArray<>(rows);
+ }
+ }
+
+ /**
+ * Returns the cached hidden-layer contribution of one pair, computing and
+ * publishing it on first sight while the budget lasts.
+ *
+ * @param model The frozen model the contributions derive from.
+ * @param position The template position.
+ * @param row The embedding row at that position.
+ * @return The contribution vector, or {@code null} when the budget is spent and
+ * the pair is not cached.
+ */
+ private float[] contribution(FeedforwardDependencyModel model, int position, int row) {
+ final AtomicReferenceArray slots = byPosition[position];
+ float[] contribution = slots.get(row);
+ if (contribution != null) {
+ return contribution;
+ }
+ if (remaining.get() <= 0) {
+ return null;
+ }
+ final int hidden = model.hiddenBias.length;
+ final float[] embedding = model.embeddings[row];
+ final int offset = position * model.embeddingSize;
+ contribution = new float[hidden];
+ for (int j = 0; j < hidden; j++) {
+ final float[] weights = model.hiddenWeights[j];
+ double sum = 0.0;
+ for (int d = 0; d < model.embeddingSize; d++) {
+ sum += weights[offset + d] * embedding[d];
+ }
+ contribution[j] = (float) sum;
+ }
+ if (slots.compareAndSet(row, null, contribution)) {
+ remaining.decrementAndGet();
+ } else {
+ contribution = slots.get(row);
+ }
+ return contribution;
+ }
+ }
+
+ /**
+ * Maps the symbolic features of {@link FeedforwardContext} onto embedding rows.
+ *
+ * @param symbols The symbolic features. Must not be {@code null}.
+ * @return The embedding row per feature. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code symbols} is {@code null}.
+ */
+ public int[] featureIds(String[] symbols) {
+ if (symbols == null) {
+ throw new IllegalArgumentException("symbols must not be null");
+ }
+ final int[] ids = new int[symbols.length];
+ for (int i = 0; i < FeedforwardContext.POSITIONS; i++) {
+ ids[i] = lookup(wordIds, normalize(symbols[i]));
+ }
+ for (int i = FeedforwardContext.POSITIONS; i < 2 * FeedforwardContext.POSITIONS; i++) {
+ ids[i] = lookup(tagIds, symbols[i]);
+ }
+ for (int i = 2 * FeedforwardContext.POSITIONS; i < symbols.length; i++) {
+ ids[i] = lookup(labelIds, symbols[i]);
+ }
+ return ids;
+ }
+
+ /**
+ * @return The transition outcome strings by output index. Never {@code null}.
+ */
+ public String[] transitions() {
+ return transitions.clone();
+ }
+
+ /**
+ * Lowercases a word symbol; special symbols and absences pass through.
+ *
+ * Case is mapped per code point through UnicodeData, the same mapping as
+ * {@link StringUtil#toLowerCase(CharSequence)}, with one contextual rule on top: a
+ * Greek capital sigma preceded by a letter and not followed by one lowercases to
+ * the final form U+03C2, the Final_Sigma condition of the Unicode
+ * SpecialCasing
+ * file restricted to a single token. Natural lowercase Greek text, and with it
+ * every vocabulary key derived from a treebank, spells a word-final sigma that
+ * way, so without the rule an uppercase Greek word would normalize to a spelling
+ * the vocabulary never contains. A word that is already lowercase, the common
+ * case at parse time, is returned unchanged without allocating.
+ *
+ * @param word The word to normalize. May be {@code null}.
+ * @return The vocabulary key of {@code word}, or {@code null} if {@code word} is
+ * {@code null}.
+ */
+ static String normalize(String word) {
+ if (word == null) {
+ return null;
+ }
+ if (word.startsWith(SPECIAL_SYMBOL_PREFIX)) {
+ return word;
+ }
+ int i = 0;
+ while (i < word.length()) {
+ final int cp = word.codePointAt(i);
+ if (Character.toLowerCase(cp) != cp) {
+ break;
+ }
+ i += Character.charCount(cp);
+ }
+ if (i == word.length()) {
+ return word;
+ }
+ final StringBuilder lowered = new StringBuilder(word.length());
+ lowered.append(word, 0, i);
+ while (i < word.length()) {
+ final int cp = word.codePointAt(i);
+ final int width = Character.charCount(cp);
+ if (cp == GREEK_CAPITAL_SIGMA && i > 0
+ && Character.isLetter(word.codePointBefore(i))
+ && (i + width >= word.length()
+ || !Character.isLetter(word.codePointAt(i + width)))) {
+ lowered.append(GREEK_SMALL_FINAL_SIGMA);
+ } else {
+ lowered.appendCodePoint(Character.toLowerCase(cp));
+ }
+ i += width;
+ }
+ return lowered.toString();
+ }
+
+ /**
+ * Resolves a symbol to its embedding row: absences map to {@link #ABSENT}, symbols
+ * without a row of their own fall back to {@link #UNKNOWN}.
+ *
+ * @param ids The vocabulary to resolve against.
+ * @param symbol The symbol to resolve, or {@code null} for an absent position.
+ * @return The embedding row of the symbol or of its fallback.
+ */
+ private static int lookup(Map ids, String symbol) {
+ Integer id = ids.get(symbol == null ? ABSENT : symbol);
+ if (id == null) {
+ id = ids.get(UNKNOWN);
+ }
+ if (id == null) {
+ throw new IllegalStateException("vocabulary has no " + UNKNOWN + " row to fall back on");
+ }
+ return id;
+ }
+
+ /**
+ * Writes the model in the versioned binary format.
+ *
+ * @param out The stream to write to. Must not be {@code null}. Not closed.
+ * @throws IOException Thrown if writing fails.
+ */
+ public void serialize(OutputStream out) throws IOException {
+ if (out == null) {
+ throw new IllegalArgumentException("out must not be null");
+ }
+ final DataOutputStream data = new DataOutputStream(new BufferedOutputStream(out));
+ data.writeUTF(MAGIC);
+ writeVocabulary(data, wordIds);
+ writeVocabulary(data, tagIds);
+ writeVocabulary(data, labelIds);
+ data.writeInt(transitions.length);
+ for (final String transition : transitions) {
+ data.writeUTF(transition);
+ }
+ data.writeInt(embeddingSize);
+ writeMatrix(data, embeddings);
+ writeMatrix(data, hiddenWeights);
+ writeVector(data, hiddenBias);
+ writeMatrix(data, outputWeights);
+ writeVector(data, outputBias);
+ data.flush();
+ }
+
+ /**
+ * Loads a model from the versioned binary format.
+ *
+ * @param in The stream to read from. Must not be {@code null}. Not closed.
+ * @return The loaded model. Never {@code null}.
+ * @throws IOException Thrown if reading fails or the content is not this format.
+ */
+ public static FeedforwardDependencyModel load(InputStream in) throws IOException {
+ if (in == null) {
+ throw new IllegalArgumentException("in must not be null");
+ }
+ final DataInputStream data = new DataInputStream(new BufferedInputStream(in));
+ final String magic = data.readUTF();
+ if (!MAGIC.equals(magic)) {
+ throw new IOException("not a feedforward dependency model: " + magic);
+ }
+ final Map wordIds = readVocabulary(data);
+ final Map tagIds = readVocabulary(data);
+ final Map labelIds = readVocabulary(data);
+ final String[] transitions = new String[data.readInt()];
+ for (int i = 0; i < transitions.length; i++) {
+ transitions[i] = data.readUTF();
+ }
+ final int embeddingSize = data.readInt();
+ return new FeedforwardDependencyModel(wordIds, tagIds, labelIds, transitions,
+ embeddingSize, readMatrix(data), readMatrix(data), readVector(data),
+ readMatrix(data), readVector(data));
+ }
+
+ /**
+ * Loads a model from a file.
+ *
+ * @param path The file to read. Must not be {@code null}.
+ * @return The loaded model. Never {@code null}.
+ * @throws IOException Thrown if reading fails or the content is not this format.
+ */
+ public static FeedforwardDependencyModel load(Path path) throws IOException {
+ if (path == null) {
+ throw new IllegalArgumentException("path must not be null");
+ }
+ try (InputStream in = Files.newInputStream(path)) {
+ return load(in);
+ }
+ }
+
+ /**
+ * Writes one vocabulary as its size followed by (symbol, id) pairs.
+ */
+ private static void writeVocabulary(DataOutputStream data, Map ids)
+ throws IOException {
+ data.writeInt(ids.size());
+ // Entries are written in ascending id order: the iteration order of the immutable
+ // maps is salted per JVM launch, and serializing the same model must produce the
+ // same bytes on every run.
+ final List> entries = new ArrayList<>(ids.entrySet());
+ entries.sort(Map.Entry.comparingByValue());
+ for (final Map.Entry entry : entries) {
+ data.writeUTF(entry.getKey());
+ data.writeInt(entry.getValue());
+ }
+ }
+
+ /**
+ * Reads one vocabulary written by {@link #writeVocabulary}.
+ */
+ private static Map readVocabulary(DataInputStream data)
+ throws IOException {
+ final int size = data.readInt();
+ final Map ids = new HashMap<>(size * 2);
+ for (int i = 0; i < size; i++) {
+ final String symbol = data.readUTF();
+ ids.put(symbol, data.readInt());
+ }
+ return ids;
+ }
+
+ /**
+ * Writes a rectangular matrix as its dimensions followed by its values in row order.
+ */
+ private static void writeMatrix(DataOutputStream data, float[][] matrix)
+ throws IOException {
+ data.writeInt(matrix.length);
+ data.writeInt(matrix.length == 0 ? 0 : matrix[0].length);
+ for (final float[] row : matrix) {
+ for (final float value : row) {
+ data.writeFloat(value);
+ }
+ }
+ }
+
+ /**
+ * Reads a matrix written by {@link #writeMatrix}.
+ */
+ private static float[][] readMatrix(DataInputStream data) throws IOException {
+ final int rows = data.readInt();
+ final int columns = data.readInt();
+ final float[][] matrix = new float[rows][columns];
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < columns; c++) {
+ matrix[r][c] = data.readFloat();
+ }
+ }
+ return matrix;
+ }
+
+ /**
+ * Writes a vector as its length followed by its values.
+ */
+ private static void writeVector(DataOutputStream data, float[] vector) throws IOException {
+ data.writeInt(vector.length);
+ for (final float value : vector) {
+ data.writeFloat(value);
+ }
+ }
+
+ /**
+ * Reads a vector written by {@link #writeVector}.
+ */
+ private static float[] readVector(DataInputStream data) throws IOException {
+ final float[] vector = new float[data.readInt()];
+ for (int i = 0; i < vector.length; i++) {
+ vector[i] = data.readFloat();
+ }
+ return vector;
+ }
+
+ /**
+ * Creates an independent copy of this model: the weights and the transition
+ * inventory array are deep-copied, and the vocabularies are shared because their
+ * maps are immutable.
+ *
+ * This lets a training pass update the copy without ever writing to a model a
+ * caller already holds, which is what keeps the immutability this class documents
+ * true.
+ *
+ * @return A copy of this model sharing no mutable state with it. Never {@code null}.
+ */
+ FeedforwardDependencyModel copy() {
+ return new FeedforwardDependencyModel(wordIds, tagIds, labelIds, transitions.clone(),
+ embeddingSize, copyOf(embeddings), copyOf(hiddenWeights), hiddenBias.clone(),
+ copyOf(outputWeights), outputBias.clone());
+ }
+
+ /**
+ * Deep-copies a matrix, row by row.
+ */
+ private static float[][] copyOf(float[][] matrix) {
+ final float[][] copy = new float[matrix.length][];
+ for (int r = 0; r < matrix.length; r++) {
+ copy[r] = matrix[r].clone();
+ }
+ return copy;
+ }
+
+ /**
+ * @return The immutable map from a normalized word to its embedding row. Never {@code null}.
+ */
+ Map wordIds() {
+ return wordIds;
+ }
+
+ /**
+ * @return The immutable map from a tag to its embedding row. Never {@code null}.
+ */
+ Map tagIds() {
+ return tagIds;
+ }
+
+ /**
+ * @return The immutable map from an arc label to its embedding row. Never {@code null}.
+ */
+ Map labelIds() {
+ return labelIds;
+ }
+
+ /**
+ * @return The width of one embedding row.
+ */
+ int embeddingSize() {
+ return embeddingSize;
+ }
+
+ /**
+ * @return The live embedding matrix, one row per vocabulary entry, not a copy: the
+ * trainer writes its updates into it. Never {@code null}.
+ */
+ float[][] embeddings() {
+ return embeddings;
+ }
+
+ /**
+ * @return The live hidden layer weights, not a copy. Never {@code null}.
+ */
+ float[][] hiddenWeights() {
+ return hiddenWeights;
+ }
+
+ /**
+ * @return The live hidden layer bias, not a copy. Never {@code null}.
+ */
+ float[] hiddenBias() {
+ return hiddenBias;
+ }
+
+ /**
+ * @return The live output layer weights, one row per transition, not a copy. Never
+ * {@code null}.
+ */
+ float[][] outputWeights() {
+ return outputWeights;
+ }
+
+ /**
+ * @return The live output layer bias, one entry per transition, not a copy. Never
+ * {@code null}.
+ */
+ float[] outputBias() {
+ return outputBias;
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java
new file mode 100644
index 0000000000..9cd96699cf
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * The pure-Java neural {@link DependencyParser}: an arc-standard decoder over the
+ * {@link FeedforwardDependencyModel}, greedy by default and beamed when constructed
+ * with a beam size above one.
+ *
+ * With a beam, the decoder keeps the highest scoring transition sequences side by
+ * side, scored by summed log-probabilities, so a single locally attractive but globally
+ * wrong transition can still be recovered while the correct parse remains inside the
+ * beam. Every complete arc-standard derivation of a sentence has the same length, which
+ * keeps the summed scores comparable without length normalization.
+ *
+ * Inference is ordinary array arithmetic with no native runtime involved, so this
+ * parser deploys exactly like the classical one while scoring configurations with
+ * learned dense representations instead of sparse feature conjunctions.
+ *
+ * The parser holds an immutable model and no per-parse state, so one instance can be
+ * shared between threads.
+ *
+ * @see FeedforwardDependencyTrainer
+ * @since 3.0.0
+ */
+public class FeedforwardDependencyParser implements DependencyParser {
+
+ private final FeedforwardDependencyModel model;
+ private final Transition[] transitions;
+ private final int beamSize;
+
+ /**
+ * Initializes a greedy {@link FeedforwardDependencyParser}.
+ *
+ * @param model The model to parse with. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code model} is {@code null} or an
+ * outcome of the model does not decode to a transition.
+ */
+ public FeedforwardDependencyParser(FeedforwardDependencyModel model) {
+ this(model, 1);
+ }
+
+ /**
+ * Initializes a {@link FeedforwardDependencyParser} with a beam.
+ *
+ * @param model The model to parse with. Must not be {@code null}.
+ * @param beamSize The number of transition sequences to keep side by side. Must be
+ * greater than zero; {@code 1} decodes greedily.
+ * @throws IllegalArgumentException Thrown if {@code model} is {@code null},
+ * {@code beamSize} is not positive, or an outcome of the model does not
+ * decode to a transition.
+ */
+ public FeedforwardDependencyParser(FeedforwardDependencyModel model, int beamSize) {
+ if (model == null) {
+ throw new IllegalArgumentException("model must not be null");
+ }
+ if (beamSize < 1) {
+ throw new IllegalArgumentException("beamSize must be positive: " + beamSize);
+ }
+ this.model = model;
+ this.beamSize = beamSize;
+ // A parser only ever reads a frozen model, so the scoring cache is safe to turn
+ // on here; training and refinement work on uncached copies.
+ model.enableScoringCache();
+ final String[] outcomes = model.transitions();
+ this.transitions = new Transition[outcomes.length];
+ for (int i = 0; i < outcomes.length; i++) {
+ transitions[i] = Transition.decode(outcomes[i]);
+ }
+ }
+
+ @Override
+ public DependencyGraph parse(String[] tokens, String[] tags) {
+ if (tokens == null || tags == null) {
+ throw new IllegalArgumentException("tokens and tags must not be null");
+ }
+ if (tokens.length == 0) {
+ throw new IllegalArgumentException("tokens must not be empty");
+ }
+ if (tokens.length != tags.length) {
+ throw new IllegalArgumentException("tokens and tags must have the same length: "
+ + tokens.length + " != " + tags.length);
+ }
+ if (beamSize == 1) {
+ return greedyParse(tokens, tags);
+ }
+ return beamParse(tokens, tags);
+ }
+
+ /**
+ * Decodes greedily: the highest scoring applicable transition wins each step.
+ *
+ * @param tokens The sentence tokens.
+ * @param tags The POS tags, aligned with {@code tokens}.
+ * @return The parse. Never {@code null}.
+ */
+ private DependencyGraph greedyParse(String[] tokens, String[] tags) {
+ final ArcStandardState state = new ArcStandardState(tokens.length);
+ while (!state.isTerminal()) {
+ final double[] scores = model.score(
+ model.featureIds(FeedforwardContext.extract(state, tokens, tags)));
+ Transition best = null;
+ double bestScore = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < scores.length; i++) {
+ if (scores[i] > bestScore && state.canApply(transitions[i])) {
+ best = transitions[i];
+ bestScore = scores[i];
+ }
+ }
+ if (best == null) {
+ throw new IllegalStateException(
+ "no applicable transition among the model outcomes in " + state);
+ }
+ state.apply(best);
+ }
+ return state.toGraph();
+ }
+
+ /** One search alternative: a configuration, its summed log-probability score, and the
+ * transition that would advance it, {@code null} once complete. */
+ private record Alternative(ArcStandardState state, double score, Transition next) {
+ }
+
+ /**
+ * Decodes with a beam: the {@code beamSize} best transition sequences advance side by
+ * side and the best complete one wins.
+ *
+ * @param tokens The sentence tokens.
+ * @param tags The POS tags, aligned with {@code tokens}.
+ * @return The parse. Never {@code null}.
+ */
+ private DependencyGraph beamParse(String[] tokens, String[] tags) {
+ List beam =
+ List.of(new Alternative(new ArcStandardState(tokens.length), 0.0, null));
+ while (true) {
+ boolean advanced = false;
+ final List expansions = new ArrayList<>();
+ for (final Alternative alternative : beam) {
+ if (alternative.state().isTerminal()) {
+ expansions.add(alternative);
+ continue;
+ }
+ advanced = true;
+ final double[] logProbabilities = logSoftmax(model.score(
+ model.featureIds(FeedforwardContext.extract(alternative.state(), tokens, tags))));
+ for (int i = 0; i < logProbabilities.length; i++) {
+ if (alternative.state().canApply(transitions[i])) {
+ expansions.add(new Alternative(alternative.state(),
+ alternative.score() + logProbabilities[i], transitions[i]));
+ }
+ }
+ }
+ if (!advanced) {
+ break;
+ }
+ expansions.sort(Comparator.comparingDouble(Alternative::score).reversed());
+ final List survivors =
+ new ArrayList<>(Math.min(beamSize, expansions.size()));
+ for (int i = 0; i < expansions.size() && survivors.size() < beamSize; i++) {
+ final Alternative expansion = expansions.get(i);
+ if (expansion.next() == null) {
+ survivors.add(expansion);
+ } else {
+ final ArcStandardState state = expansion.state().copy();
+ state.apply(expansion.next());
+ survivors.add(new Alternative(state, expansion.score(), null));
+ }
+ }
+ if (survivors.isEmpty()) {
+ throw new IllegalStateException(
+ "no applicable transition among the model outcomes in the beam");
+ }
+ beam = survivors;
+ }
+ return beam.get(0).state().toGraph();
+ }
+
+ /**
+ * Normalizes raw transition scores to log-probabilities.
+ *
+ * @param scores The raw output scores.
+ * @return The log-softmax of {@code scores}. Never {@code null}.
+ */
+ private double[] logSoftmax(double[] scores) {
+ double max = Double.NEGATIVE_INFINITY;
+ for (final double score : scores) {
+ max = Math.max(max, score);
+ }
+ double sum = 0.0;
+ for (final double score : scores) {
+ sum += Math.exp(score - max);
+ }
+ final double logSum = max + Math.log(sum);
+ final double[] logProbabilities = new double[scores.length];
+ for (int i = 0; i < scores.length; i++) {
+ logProbabilities[i] = scores[i] - logSum;
+ }
+ return logProbabilities;
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java
new file mode 100644
index 0000000000..7f7e0c07ed
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java
@@ -0,0 +1,950 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * 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.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.util.ObjectStream;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel} entirely in Java: oracle-derived
+ * transition examples, minibatch AdaGrad over a softmax cross-entropy loss, cube
+ * activation, and inverted dropout on the hidden layer, the training recipe of
+ * Chen and Manning (2014). No external
+ * training framework is involved, so the whole neural tier, training and inference, is
+ * plain array arithmetic inside the JVM.
+ *
+ * Words below the frequency cutoff share a learned unknown embedding; absent
+ * template positions share a learned padding embedding. Non-projective samples have no
+ * arc-standard derivation and are skipped. Training is deterministic for a fixed
+ * {@link Settings#seed()}.
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+ private static final double ADAGRAD_EPSILON = 1e-6;
+
+ private FeedforwardDependencyTrainer() {
+ // This class only exposes static training methods and is never instantiated.
+ }
+
+ /**
+ * The training hyperparameters.
+ *
+ * @param embeddingSize The embedding dimensionality. Must be positive.
+ * @param hiddenSize The hidden layer width. Must be positive.
+ * @param epochs The number of passes over the examples. Must be positive.
+ * @param batchSize The minibatch size. Must be positive.
+ * @param learningRate The AdaGrad step size. Must be positive.
+ * @param l2 The L2 penalty applied to the dense weights. Must not be negative.
+ * @param dropout The hidden dropout probability. Must be in {@code [0, 1)}.
+ * @param wordCutoff The minimum frequency for a word to get its own embedding. Must
+ * not be negative.
+ * @param seed The random seed making a run reproducible.
+ */
+ public record Settings(int embeddingSize, int hiddenSize, int epochs, int batchSize,
+ double learningRate, double l2, double dropout, int wordCutoff, long seed) {
+
+ /**
+ * Validates the hyperparameters.
+ *
+ * @throws IllegalArgumentException Thrown if a value is outside its documented
+ * range.
+ */
+ public Settings {
+ if (embeddingSize <= 0 || hiddenSize <= 0 || epochs <= 0 || batchSize <= 0) {
+ throw new IllegalArgumentException("sizes, epochs and batch must be positive");
+ }
+ if (learningRate <= 0.0 || l2 < 0.0) {
+ throw new IllegalArgumentException("learningRate must be positive, l2 not negative");
+ }
+ if (!(dropout >= 0.0 && dropout < 1.0)) {
+ throw new IllegalArgumentException("dropout must be in [0, 1): " + dropout);
+ }
+ if (wordCutoff < 0) {
+ throw new IllegalArgumentException("wordCutoff must not be negative");
+ }
+ }
+
+ /**
+ * @return The default hyperparameters. Never {@code null}.
+ */
+ public static Settings defaults() {
+ return new Settings(50, 200, 10, 256, 0.02, 1e-8, 0.5, 2, 17L);
+ }
+ }
+
+ /**
+ * Trains a model from dependency samples.
+ *
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters. Must not be {@code null}.
+ * @return A trained {@link FeedforwardDependencyModel}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if a parameter is {@code null} or no
+ * trainable example can be derived from the samples.
+ */
+ public static FeedforwardDependencyModel train(ObjectStream samples,
+ Settings settings) throws IOException {
+ return train(samples, settings, null);
+ }
+
+ /**
+ * Trains a model from dependency samples, seeding word embeddings from a pretrained
+ * source.
+ *
+ * The provider is consulted once per vocabulary word during initialization; words
+ * it returns {@code null} for keep their random initialization, and all embeddings
+ * remain trainable afterwards. The pretrained source is a training-time ingredient
+ * only: the learned embeddings ship inside the model, so parsing carries no
+ * dependency on the source.
+ *
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters. Must not be {@code null}.
+ * @param pretrained Maps a normalized word to its pretrained vector of exactly
+ * {@link Settings#embeddingSize()} dimensions, or {@code null} for
+ * unknown words. May be {@code null} to disable seeding.
+ * @return A trained {@link FeedforwardDependencyModel}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if {@code samples} or {@code settings} is
+ * {@code null}, no trainable example can be derived, or a pretrained vector
+ * has the wrong dimensionality.
+ */
+ public static FeedforwardDependencyModel train(ObjectStream samples,
+ Settings settings, Function pretrained)
+ throws IOException {
+ if (samples == null || settings == null) {
+ throw new IllegalArgumentException("samples and settings must not be null");
+ }
+ final List corpus = readAll(samples);
+ final FeedforwardDependencyModel model = initialize(corpus, settings);
+ if (pretrained != null) {
+ seed(model, pretrained, settings);
+ }
+ final List featureList = new ArrayList<>();
+ final List goldList = new ArrayList<>();
+ collectExamples(corpus, model, featureList, goldList);
+ if (featureList.isEmpty()) {
+ throw new IllegalArgumentException("no trainable examples in the samples");
+ }
+ optimize(model, featureList, goldList, settings);
+ return model;
+ }
+
+ /**
+ * Fine-tunes a locally trained model globally: sentences are decoded with a beam, the
+ * gold derivation is tracked through it, and the moment the gold prefix falls out of
+ * the beam an early update, in the sense of
+ * Collins and Roark (2004), pushes
+ * the model toward keeping it. The loss is a conditional likelihood over the beam's
+ * candidate paths, scored exactly like the beamed parser scores them, summed
+ * log-probabilities, so training optimizes the quantity decoding uses.
+ *
+ * The refined weights are a copy: {@code model} itself is never written to, so a
+ * model already being parsed with, possibly by several threads, keeps behaving exactly
+ * as before while a refined successor is trained from it. The copy is updated with
+ * per-sentence AdaGrad steps and no dropout; {@link Settings#epochs()} counts the
+ * refinement passes. Refinement is deterministic for a fixed {@link Settings#seed()}.
+ * Parse afterwards with the same beam size.
+ *
+ * The transition inventory comes from {@code model} and is not extended, because
+ * its size is the width of the trained output layer. A refinement corpus using a
+ * relation label the original training set lacked is therefore rejected rather than
+ * silently ignored.
+ *
+ * @param model The locally trained model to refine. Left untouched. Must not be
+ * {@code null}.
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters; {@code epochs}, {@code learningRate},
+ * {@code l2}, and {@code seed} apply. Must not be {@code null}.
+ * @param beamSize The beam width to track the gold derivation in. Must be at least 2.
+ * @return A new refined model, distinct from {@code model}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if a parameter is {@code null},
+ * {@code beamSize} is below 2, no trainable sample can be derived, or a sample
+ * requires a transition {@code model} does not know.
+ */
+ public static FeedforwardDependencyModel refine(FeedforwardDependencyModel model,
+ ObjectStream samples, Settings settings, int beamSize)
+ throws IOException {
+ if (model == null || samples == null || settings == null) {
+ throw new IllegalArgumentException("model, samples and settings must not be null");
+ }
+ if (beamSize < 2) {
+ throw new IllegalArgumentException("beamSize must be at least 2: " + beamSize);
+ }
+ final List corpus = readAll(samples);
+ final String[] outcomes = model.transitions();
+ final Map transitionIds = new HashMap<>();
+ final Transition[] transitions = new Transition[outcomes.length];
+ for (int i = 0; i < transitions.length; i++) {
+ transitionIds.put(outcomes[i], i);
+ transitions[i] = Transition.decode(outcomes[i]);
+ }
+
+ final List trainable = new ArrayList<>();
+ final List oracles = new ArrayList<>();
+ for (final DependencySample s : corpus) {
+ final List oracle;
+ try {
+ oracle = ArcStandardOracle.transitions(s.getGraph());
+ } catch (IllegalArgumentException e) {
+ continue;
+ }
+ final int[] encoded = new int[oracle.size()];
+ for (int i = 0; i < encoded.length; i++) {
+ final String outcome = oracle.get(i).encode();
+ final Integer id = transitionIds.get(outcome);
+ if (id == null) {
+ // The outcome space was fixed by the original training set, so a relation
+ // label it never saw has no output unit to push probability onto.
+ throw new IllegalArgumentException(
+ "unknown transition in the refinement samples: " + outcome);
+ }
+ encoded[i] = id;
+ }
+ trainable.add(s);
+ oracles.add(encoded);
+ }
+ if (trainable.isEmpty()) {
+ throw new IllegalArgumentException("no trainable samples for refinement");
+ }
+
+ final FeedforwardDependencyModel refined = model.copy();
+ final GlobalOptimizer optimizer = new GlobalOptimizer(refined, settings);
+ final Random random = new Random(settings.seed());
+ final int[] order = new int[trainable.size()];
+ for (int i = 0; i < order.length; i++) {
+ order[i] = i;
+ }
+ for (int epoch = 1; epoch <= settings.epochs(); epoch++) {
+ final long epochStart = System.currentTimeMillis();
+ shuffle(order, random);
+ double loss = 0.0;
+ int updates = 0;
+ for (final int index : order) {
+ final double sentenceLoss = optimizer.refineSentence(trainable.get(index),
+ oracles.get(index), transitions, beamSize);
+ if (sentenceLoss >= 0.0) {
+ loss += sentenceLoss;
+ updates++;
+ }
+ }
+ logger.info("refine epoch {}: loss {} over {} updates in {} ms", epoch,
+ loss / Math.max(updates, 1), updates, System.currentTimeMillis() - epochStart);
+ }
+ return refined;
+ }
+
+ /** One candidate path in the refinement beam: the parent link forms the history. */
+ private static final class BeamNode {
+ private final BeamNode parent;
+ private final int[] features;
+ private final int transition;
+ private final double score;
+ private final boolean gold;
+ private ArcStandardState state;
+
+ /** Extends {@code parent} by one transition; the start node passes {@code null}. */
+ private BeamNode(BeamNode parent, int[] features, int transition, double score,
+ boolean gold) {
+ this.parent = parent;
+ this.features = features;
+ this.transition = transition;
+ this.score = score;
+ this.gold = gold;
+ }
+ }
+
+ /** The forward, backward, and AdaGrad state for global refinement. */
+ private static final class GlobalOptimizer {
+ private final FeedforwardDependencyModel model;
+ private final Settings settings;
+ private final int embeddingSize;
+ private final int hiddenSize;
+ private final int outputSize;
+ private final int inputSize;
+
+ private final double[][] embeddingAccumulator;
+ private final double[][] hiddenAccumulator;
+ private final double[] hiddenBiasAccumulator;
+ private final double[][] outputAccumulator;
+ private final double[] outputBiasAccumulator;
+
+ private final double[][] hiddenGradient;
+ private final double[] hiddenBiasGradient;
+ private final double[][] outputGradient;
+ private final double[] outputBiasGradient;
+ private final Map embeddingGradients = new HashMap<>();
+
+ private final double[] x;
+ private final double[] pre;
+ private final double[] hidden;
+ private final double[] probabilities;
+ private final double[] hiddenDelta;
+ private final double[] inputDelta;
+
+ /** Sizes the accumulators and scratch buffers for one refinement run over {@code model}. */
+ private GlobalOptimizer(FeedforwardDependencyModel model, Settings settings) {
+ this.model = model;
+ this.settings = settings;
+ this.embeddingSize = model.embeddings()[0].length;
+ this.hiddenSize = model.hiddenBias().length;
+ this.outputSize = model.outputBias().length;
+ this.inputSize =
+ (2 * FeedforwardContext.POSITIONS + FeedforwardContext.LABEL_POSITIONS)
+ * embeddingSize;
+ this.embeddingAccumulator =
+ new double[model.embeddings().length][embeddingSize];
+ this.hiddenAccumulator = new double[hiddenSize][inputSize];
+ this.hiddenBiasAccumulator = new double[hiddenSize];
+ this.outputAccumulator = new double[outputSize][hiddenSize];
+ this.outputBiasAccumulator = new double[outputSize];
+ this.hiddenGradient = new double[hiddenSize][inputSize];
+ this.hiddenBiasGradient = new double[hiddenSize];
+ this.outputGradient = new double[outputSize][hiddenSize];
+ this.outputBiasGradient = new double[outputSize];
+ this.x = new double[inputSize];
+ this.pre = new double[hiddenSize];
+ this.hidden = new double[hiddenSize];
+ this.probabilities = new double[outputSize];
+ this.hiddenDelta = new double[hiddenSize];
+ this.inputDelta = new double[inputSize];
+ }
+
+ /**
+ * Decodes one sentence with the beam, updating on the early-update point or the
+ * final beam.
+ *
+ * @param sample The sentence.
+ * @param oracle The gold transition indexes.
+ * @param transitions The decoded transition inventory.
+ * @param beamSize The beam width.
+ * @return The sentence loss, or {@code -1} when the sentence produced no update.
+ */
+ private double refineSentence(DependencySample sample, int[] oracle,
+ Transition[] transitions, int beamSize) {
+ final String[] tokens = sample.getTokens();
+ final String[] tags = sample.getTags();
+ final BeamNode root = new BeamNode(null, null, -1, 0.0, true);
+ root.state = new ArcStandardState(tokens.length);
+ List beam = List.of(root);
+
+ for (int step = 0; step < oracle.length; step++) {
+ final List expansions = new ArrayList<>();
+ BeamNode goldChild = null;
+ for (final BeamNode node : beam) {
+ final int[] features =
+ model.featureIds(FeedforwardContext.extract(node.state, tokens, tags));
+ forward(features);
+ logSoftmaxInPlace(probabilities);
+ for (int i = 0; i < outputSize; i++) {
+ if (node.state.canApply(transitions[i])) {
+ final boolean goldNext = node.gold && i == oracle[step];
+ final BeamNode child = new BeamNode(node, features, i,
+ node.score + probabilities[i], goldNext);
+ expansions.add(child);
+ if (goldNext) {
+ goldChild = child;
+ }
+ }
+ }
+ }
+ expansions.sort((a, b) -> Double.compare(b.score, a.score));
+ final List survivors =
+ new ArrayList<>(expansions.subList(0, Math.min(beamSize, expansions.size())));
+ boolean goldSurvives = false;
+ for (final BeamNode survivor : survivors) {
+ if (survivor.gold) {
+ goldSurvives = true;
+ break;
+ }
+ }
+ if (!goldSurvives) {
+ if (goldChild == null) {
+ // The gold transition was not applicable in the gold configuration, so
+ // this sentence yields no update.
+ return -1.0;
+ }
+ survivors.add(goldChild);
+ return updateFromCandidates(survivors);
+ }
+ if (step == oracle.length - 1) {
+ return updateFromCandidates(survivors);
+ }
+ for (final BeamNode survivor : survivors) {
+ survivor.state = survivor.parent.state.copy();
+ survivor.state.apply(transitions[survivor.transition]);
+ }
+ beam = survivors;
+ }
+ return -1.0;
+ }
+
+ /** Applies the conditional-likelihood update over the candidate paths. */
+ private double updateFromCandidates(List candidates) {
+ double max = Double.NEGATIVE_INFINITY;
+ double goldScore = Double.NEGATIVE_INFINITY;
+ for (final BeamNode candidate : candidates) {
+ max = Math.max(max, candidate.score);
+ if (candidate.gold) {
+ goldScore = candidate.score;
+ }
+ }
+ double normalizer = 0.0;
+ for (final BeamNode candidate : candidates) {
+ normalizer += Math.exp(candidate.score - max);
+ }
+ final double logNormalizer = max + Math.log(normalizer);
+
+ zero(hiddenGradient);
+ Arrays.fill(hiddenBiasGradient, 0.0);
+ zero(outputGradient);
+ Arrays.fill(outputBiasGradient, 0.0);
+ embeddingGradients.clear();
+ for (final BeamNode candidate : candidates) {
+ final double weight = Math.exp(candidate.score - logNormalizer)
+ - (candidate.gold ? 1.0 : 0.0);
+ if (weight == 0.0) {
+ continue;
+ }
+ for (BeamNode node = candidate; node.parent != null; node = node.parent) {
+ backward(node.features, node.transition, weight);
+ }
+ }
+ update(model.hiddenWeights(), hiddenGradient, hiddenAccumulator, 1, settings);
+ updateVector(model.hiddenBias(), hiddenBiasGradient, hiddenBiasAccumulator, 1,
+ settings);
+ update(model.outputWeights(), outputGradient, outputAccumulator, 1, settings);
+ updateVector(model.outputBias(), outputBiasGradient, outputBiasAccumulator, 1,
+ settings);
+ for (final Map.Entry entry : embeddingGradients.entrySet()) {
+ final float[] embeddingRow = model.embeddings()[entry.getKey()];
+ final double[] accumulatorRow = embeddingAccumulator[entry.getKey()];
+ final double[] gradientRow = entry.getValue();
+ for (int d = 0; d < embeddingSize; d++) {
+ final double gradient = gradientRow[d];
+ accumulatorRow[d] += gradient * gradient;
+ embeddingRow[d] -= settings.learningRate() * gradient
+ / (Math.sqrt(accumulatorRow[d]) + ADAGRAD_EPSILON);
+ }
+ }
+ return logNormalizer - goldScore;
+ }
+
+ /** Computes hidden activations and raw output scores for one feature vector. */
+ private void forward(int[] features) {
+ final float[][] embeddings = model.embeddings();
+ for (int f = 0; f < features.length; f++) {
+ final float[] embedding = embeddings[features[f]];
+ final int offset = f * embeddingSize;
+ for (int d = 0; d < embeddingSize; d++) {
+ x[offset + d] = embedding[d];
+ }
+ }
+ final float[][] hiddenWeights = model.hiddenWeights();
+ final float[] hiddenBias = model.hiddenBias();
+ for (int j = 0; j < hiddenSize; j++) {
+ final float[] weightRow = hiddenWeights[j];
+ double sum = hiddenBias[j];
+ for (int k = 0; k < inputSize; k++) {
+ sum += weightRow[k] * x[k];
+ }
+ pre[j] = sum;
+ hidden[j] = sum * sum * sum;
+ }
+ final float[][] outputWeights = model.outputWeights();
+ final float[] outputBias = model.outputBias();
+ for (int o = 0; o < outputSize; o++) {
+ final float[] weightRow = outputWeights[o];
+ double sum = outputBias[o];
+ for (int j = 0; j < hiddenSize; j++) {
+ sum += weightRow[j] * hidden[j];
+ }
+ probabilities[o] = sum;
+ }
+ }
+
+ /**
+ * Accumulates gradients for one decoded step: the weighted difference between the
+ * step's softmax and its chosen transition.
+ *
+ * @param features The step's input features.
+ * @param chosen The transition the path took at this step.
+ * @param weight The path's weight in the candidate distribution.
+ */
+ private void backward(int[] features, int chosen, double weight) {
+ forward(features);
+ double max = Double.NEGATIVE_INFINITY;
+ for (int o = 0; o < outputSize; o++) {
+ max = Math.max(max, probabilities[o]);
+ }
+ double normalizer = 0.0;
+ for (int o = 0; o < outputSize; o++) {
+ probabilities[o] = Math.exp(probabilities[o] - max);
+ normalizer += probabilities[o];
+ }
+ Arrays.fill(hiddenDelta, 0.0);
+ Arrays.fill(inputDelta, 0.0);
+ final float[][] outputWeights = model.outputWeights();
+ for (int o = 0; o < outputSize; o++) {
+ // dL/dlogit for a path's step under the conditional likelihood: the path weight
+ // times how the step's log-probability responds to this logit
+ final double delta =
+ weight * ((o == chosen ? 1.0 : 0.0) - probabilities[o] / normalizer);
+ outputBiasGradient[o] += delta;
+ final double[] gradientRow = outputGradient[o];
+ final float[] weightRow = outputWeights[o];
+ for (int j = 0; j < hiddenSize; j++) {
+ gradientRow[j] += delta * hidden[j];
+ hiddenDelta[j] += delta * weightRow[j];
+ }
+ }
+ final float[][] hiddenWeights = model.hiddenWeights();
+ for (int j = 0; j < hiddenSize; j++) {
+ final double preDelta = hiddenDelta[j] * 3.0 * pre[j] * pre[j];
+ hiddenBiasGradient[j] += preDelta;
+ final double[] gradientRow = hiddenGradient[j];
+ final float[] weightRow = hiddenWeights[j];
+ for (int k = 0; k < inputSize; k++) {
+ gradientRow[k] += preDelta * x[k];
+ inputDelta[k] += preDelta * weightRow[k];
+ }
+ }
+ for (int f = 0; f < features.length; f++) {
+ final double[] embeddingGradient = embeddingGradients
+ .computeIfAbsent(features[f], key -> new double[embeddingSize]);
+ final int offset = f * embeddingSize;
+ for (int d = 0; d < embeddingSize; d++) {
+ embeddingGradient[d] += inputDelta[offset + d];
+ }
+ }
+ }
+
+ /** Turns raw scores into log-probabilities in place. */
+ private void logSoftmaxInPlace(double[] scores) {
+ double max = Double.NEGATIVE_INFINITY;
+ for (final double score : scores) {
+ max = Math.max(max, score);
+ }
+ double sum = 0.0;
+ for (final double score : scores) {
+ sum += Math.exp(score - max);
+ }
+ final double logSum = max + Math.log(sum);
+ for (int i = 0; i < scores.length; i++) {
+ scores[i] -= logSum;
+ }
+ }
+ }
+
+ /**
+ * Reads a sample stream into memory; both trainers pass over the corpus repeatedly.
+ *
+ * @param samples The stream to drain.
+ * @return All samples in stream order. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ */
+ private static List readAll(ObjectStream samples)
+ throws IOException {
+ final List corpus = new ArrayList<>();
+ DependencySample sample;
+ while ((sample = samples.read()) != null) {
+ corpus.add(sample);
+ }
+ return corpus;
+ }
+
+ /** Overwrites the random word rows with pretrained vectors where available. */
+ private static void seed(FeedforwardDependencyModel model,
+ Function pretrained, Settings settings) {
+ int seeded = 0;
+ for (final Map.Entry entry : model.wordIds().entrySet()) {
+ if (entry.getKey().startsWith(FeedforwardDependencyModel.SPECIAL_SYMBOL_PREFIX)) {
+ // The special unknown, padding, and root symbols have no pretrained
+ // counterpart, so they keep their random initialization.
+ continue;
+ }
+ final float[] vector = pretrained.apply(entry.getKey());
+ if (vector == null) {
+ continue;
+ }
+ if (vector.length != settings.embeddingSize()) {
+ throw new IllegalArgumentException("pretrained vector for '" + entry.getKey()
+ + "' has " + vector.length + " dimensions, expected "
+ + settings.embeddingSize());
+ }
+ System.arraycopy(vector, 0, model.embeddings()[entry.getValue()], 0, vector.length);
+ seeded++;
+ }
+ logger.info("seeded {} of {} word embeddings from the pretrained source", seeded,
+ model.wordIds().size());
+ }
+
+ /** Builds the vocabularies and randomly initialized weights. */
+ private static FeedforwardDependencyModel initialize(List corpus,
+ Settings settings) {
+ final Map wordCounts = new HashMap<>();
+ final Map tagIds = new HashMap<>();
+ final Map labelIds = new HashMap<>();
+ final Map