[ 
https://issues.apache.org/jira/browse/OPENNLP-1991?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18126905#comment-18126905
 ] 

ASF GitHub Bot commented on OPENNLP-1991:
-----------------------------------------

krickert commented on code in PR #1323:
URL: https://github.com/apache/opennlp/pull/1323#discussion_r4266430860


##########
opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java:
##########
@@ -544,30 +544,145 @@ public static boolean isUnicodeBlank(CharSequence input) 
{
     return true;
   }
 
+  /** U+03A3, GREEK CAPITAL LETTER SIGMA, the one code point whose lowering 
depends on context. */
+  private static final int GREEK_CAPITAL_SIGMA = 0x03A3;
+
+  /** U+03C2, GREEK SMALL LETTER FINAL SIGMA, the lowering of the capital at 
the end of a word. */
+  private static final int GREEK_SMALL_FINAL_SIGMA = 0x03C2;
+
   /**
    * Converts a {@link CharSequence} to lower case, independent of the current
-   * {@link java.util.Locale} via {@link Character#toLowerCase(int)} which uses
-   * mapping information from the UnicodeData file.
+   * {@link java.util.Locale}. Each code point maps through
+   * {@link Character#toLowerCase(int)}, the UnicodeData mapping, so no 
character
+   * expands. The one contextual rule of the Unicode
+   * <a 
href="https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt";>SpecialCasing</a>
+   * data applies as well: a Greek capital sigma at the end of a word, after a 
cased
+   * letter and before no further cased letter, becomes the final sigma U+03C2.
    *
    * @param string The {@link CharSequence} to transform.
    * @return The lower-cased String.
    */
   public static String toLowerCase(CharSequence string) {
-    int[] cp = string.codePoints().map(Character::toLowerCase).toArray();
+    int[] cp = toLowerCaseCodePoints(string);
     return new String(cp, 0, cp.length);
   }
 
+  /**
+   * Converts a {@link CharSequence} to lower case into a {@link CharBuffer}, 
with the
+   * mapping of {@link #toLowerCase(CharSequence)}.
+   *
+   * @param sequence The {@link CharSequence} to transform.
+   * @return The lower-cased characters, positioned at the start.
+   */
   public static CharBuffer toLowerCaseCharBuffer(CharSequence sequence) {
     CharBuffer result = CharBuffer.allocate(sequence.length());
-    for (int cp : sequence.codePoints().toArray()) {
-      for (char c : Character.toChars(Character.toLowerCase(cp))) {
-        result.append(c);
+    for (int cp : toLowerCaseCodePoints(sequence)) {
+      if (Character.isBmpCodePoint(cp)) {
+        result.put((char) cp);
+      } else {
+        result.put(Character.highSurrogate(cp));
+        result.put(Character.lowSurrogate(cp));
       }
     }
     result.clear();
     return result;
   }
 
+  /**
+   * Lowers every code point of a sequence, applying the Final_Sigma context.
+   *
+   * @param sequence The {@link CharSequence} to transform.
+   * @return The lower-cased code points.
+   */
+  private static int[] toLowerCaseCodePoints(CharSequence sequence) {
+    final int[] source = sequence.codePoints().toArray();
+    final int[] lowered = new int[source.length];
+    for (int i = 0; i < source.length; i++) {

Review Comment:
   Reverted StringUtil to main. The sigma rule now lives in 
FeedforwardNetwork.normalize with a parameterized test there.
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardNetwork.java:
##########
@@ -0,0 +1,1011 @@
+/*
+ * 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.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import opennlp.tools.ml.ArrayMath;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.model.ArtifactSerializer;
+
+/**
+ * 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, after
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The network holds every forward computation the parser and the trainer 
share:
+ * {@link #addContribution} is the per-position hidden sum, {@link 
#outputScores} the
+ * output layer, {@link #score} the complete inference pass, and
+ * {@link #logSoftmaxInPlace} the normalization of beam decoding. The trainer 
writes
+ * its updates into the live arrays the accessors return; the parser never 
modifies
+ * them, so a network that is not being trained can be shared between 
threads.</p>
+ *
+ * <p>Each vocabulary occupies one consecutive block of embedding rows and 
starts with
+ * its reserved symbols, so unknown symbols, absent positions, and the 
artificial root
+ * always have a row. Words are matched after {@link #normalize(String) 
normalization}.</p>
+ *
+ * @see FeedforwardDependencyModel
+ * @since 3.0.0
+ */
+final class FeedforwardNetwork {
+
+  /** The vocabulary key of every word, tag, or label the network has no 
embedding row for. */
+  static final String UNKNOWN = "*UNK*";
+
+  /** The reserved word symbols, in the order of their embedding rows. */
+  static final String[] RESERVED_WORD_SYMBOLS = {UNKNOWN,
+      DependencyContextGenerator.NONE_VALUE, 
DependencyContextGenerator.ROOT_VALUE};
+
+  /** The reserved tag symbols, in the order of their embedding rows. */
+  static final String[] RESERVED_TAG_SYMBOLS = {UNKNOWN,
+      DependencyContextGenerator.NONE_VALUE, 
DependencyContextGenerator.ROOT_VALUE};
+
+  /** The reserved label symbols, in the order of their embedding rows. */
+  static final String[] RESERVED_LABEL_SYMBOLS = {UNKNOWN,
+      DependencyContextGenerator.NONE_VALUE};
+
+  /** The embedding rows the reserved symbols of all three vocabularies take. 
*/
+  static final int RESERVED_ROWS = RESERVED_WORD_SYMBOLS.length
+      + RESERVED_TAG_SYMBOLS.length + RESERVED_LABEL_SYMBOLS.length;
+
+  /** The embedding row of the unknown symbol in each vocabulary block. */
+  private static final int UNKNOWN_OFFSET = 0;
+
+  /** The embedding row of the absent-position symbol in each vocabulary 
block. */
+  private static final int NONE_OFFSET = 1;
+
+  /** The embedding row of the root symbol in the word and tag blocks. */
+  private static final int ROOT_OFFSET = 2;
+
+  private final Map<String, Integer> wordIds;
+  private final Map<String, Integer> tagIds;
+  private final Map<String, Integer> labelIds;
+
+  /** The first embedding row of each vocabulary, indexed like the 
vocabularies above. */
+  private final int firstWordRow;
+  private final int firstTagRow;
+  private final int firstLabelRow;
+
+  private final String[] transitions;
+  private final Transition[] decodedTransitions;
+  private final Map<Transition, Integer> transitionIds;
+
+  private final int embeddingSize;
+  private final float[][] embeddings;
+  private final float[][] hiddenWeights;
+  private final float[] hiddenBias;
+  private final float[][] outputWeights;
+  private final float[] outputBias;
+
+  /**
+   * Assembles a network from its vocabularies and weights, copying the maps 
so later
+   * changes by the caller do not reach the network.
+   *
+   * @param wordIds The word vocabulary, mapping each normalized word to its 
row.
+   * @param tagIds The tag vocabulary.
+   * @param labelIds The dependency label vocabulary.
+   * @param transitions The transition inventory, indexed by output row.
+   * @param embeddingSize The embedding dimensionality.
+   * @param embeddings The embedding rows for words, tags, and labels.
+   * @param hiddenWeights The hidden layer weights.
+   * @param hiddenBias The hidden layer bias.
+   * @param outputWeights The output layer weights.
+   * @param outputBias The output layer bias.
+   * @throws IllegalArgumentException Thrown if a vocabulary lacks a reserved 
symbol, the
+   *         vocabularies do not partition the embedding rows into consecutive 
blocks
+   *         that start with the reserved symbols, a transition repeats or 
does not
+   *         decode, the inventory lacks SHIFT or RIGHT_ARC, or a weight 
dimension does
+   *         not match.
+   */
+  FeedforwardNetwork(Map<String, Integer> wordIds, Map<String, Integer> tagIds,
+      Map<String, Integer> 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.clone();
+    this.embeddingSize = embeddingSize;
+    this.embeddings = embeddings;
+    this.hiddenWeights = hiddenWeights;
+    this.hiddenBias = hiddenBias;
+    this.outputWeights = outputWeights;
+    this.outputBias = outputBias;
+
+    final long rows = (long) wordIds.size() + tagIds.size() + labelIds.size();
+    if (rows != embeddings.length) {
+      throw new IllegalArgumentException("embedding matrix has " + 
embeddings.length
+          + " rows for " + rows + " vocabulary entries");
+    }
+    final boolean[] taken = new boolean[embeddings.length];
+    this.firstWordRow = validateVocabulary(this.wordIds, taken, "word 
vocabulary",
+        RESERVED_WORD_SYMBOLS);
+    this.firstTagRow = validateVocabulary(this.tagIds, taken, "tag vocabulary",
+        RESERVED_TAG_SYMBOLS);
+    this.firstLabelRow = validateVocabulary(this.labelIds, taken, "label 
vocabulary",
+        RESERVED_LABEL_SYMBOLS);
+
+    this.decodedTransitions = new Transition[this.transitions.length];
+    this.transitionIds = new HashMap<>();
+    boolean hasShift = false;
+    boolean hasRightArc = false;
+    for (int i = 0; i < this.transitions.length; i++) {
+      try {
+        decodedTransitions[i] = Transition.decode(this.transitions[i]);
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException("invalid transition: " + 
this.transitions[i], e);
+      }
+      if (transitionIds.put(decodedTransitions[i], i) != null) {
+        throw new IllegalArgumentException("duplicate transition: " + 
this.transitions[i]);
+      }
+      hasShift |= decodedTransitions[i].type() == Transition.Type.SHIFT;
+      hasRightArc |= decodedTransitions[i].type() == Transition.Type.RIGHT_ARC;
+    }
+    if (!hasShift) {
+      throw new IllegalArgumentException("transition inventory has no SHIFT 
action");
+    }
+    if (!hasRightArc) {
+      throw new IllegalArgumentException("transition inventory has no 
RIGHT_ARC action");
+    }
+
+    final int inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+    requireColumns(embeddings, embeddingSize, "embedding matrix");
+    requireColumns(hiddenWeights, inputSize, "hidden matrix");
+    if (hiddenBias.length != hiddenWeights.length) {
+      throw new IllegalArgumentException("hidden bias length is " + 
hiddenBias.length
+          + ", expected " + hiddenWeights.length);
+    }
+    if (outputWeights.length != this.transitions.length) {
+      throw new IllegalArgumentException("output matrix row count is "
+          + outputWeights.length + ", expected " + this.transitions.length);
+    }
+    requireColumns(outputWeights, hiddenWeights.length, "output matrix");
+    if (outputBias.length != this.transitions.length) {
+      throw new IllegalArgumentException("output bias length is " + 
outputBias.length
+          + ", expected " + this.transitions.length);
+    }
+  }
+
+  /**
+   * Checks that one vocabulary starts with its reserved symbols and occupies 
one
+   * consecutive block of embedding rows that no other vocabulary uses.
+   *
+   * @param ids The vocabulary to check.
+   * @param taken The rows claimed so far across all vocabularies; updated in 
place.
+   * @param label The vocabulary name used in error messages.
+   * @param reserved The reserved symbols, in the order of their rows.
+   * @return The first row of the block.
+   * @throws IllegalArgumentException Thrown if the block is not consecutive, 
a row is
+   *         already taken, or a reserved symbol is missing or misplaced.
+   */
+  private static int validateVocabulary(Map<String, Integer> ids, boolean[] 
taken,
+      String label, String[] reserved) {
+    int min = Integer.MAX_VALUE;
+    int max = Integer.MIN_VALUE;
+    for (final Map.Entry<String, Integer> entry : ids.entrySet()) {
+      final int id = entry.getValue();
+      if (id < 0 || id >= taken.length) {
+        throw new IllegalArgumentException(label + " id out of range for "
+            + entry.getKey() + ": " + id);
+      }
+      if (taken[id]) {
+        throw new IllegalArgumentException("duplicate embedding id: " + id);
+      }
+      taken[id] = true;
+      min = Math.min(min, id);
+      max = Math.max(max, id);
+    }
+    if (ids.size() < reserved.length || max - min + 1 != ids.size()) {
+      throw new IllegalArgumentException(label + " must occupy one consecutive 
block of "
+          + "embedding rows that starts with its reserved symbols");
+    }
+    for (int i = 0; i < reserved.length; i++) {
+      final Integer id = ids.get(reserved[i]);
+      if (id == null || id != min + i) {
+        throw new IllegalArgumentException(label + " must have " + reserved[i]
+            + " at row " + (min + i));
+      }
+    }
+    return min;
+  }
+
+  /**
+   * @param matrix The matrix to check.
+   * @param columns The required width of every row.
+   * @param label The matrix name used in error messages.
+   * @throws IllegalArgumentException Thrown if a row has another width.
+   */
+  private static void requireColumns(float[][] matrix, int columns, String 
label) {
+    for (final float[] row : matrix) {
+      if (row.length != columns) {
+        throw new IllegalArgumentException(label + " column count is " + 
row.length
+            + ", expected " + columns);
+      }
+    }
+  }
+
+  /**
+   * Scores every transition for a configuration described by embedding rows.
+   *
+   * @param features The embedding rows of the configuration, as produced by
+   *                 {@link FeedforwardContext#featureIds}. Must not be {@code 
null}.
+   * @param cache The contribution cache to read, or {@code null} to compute 
every
+   *              contribution.
+   * @return One unnormalized score per transition, indexed like
+   *         {@link #transitions()}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code features} is {@code 
null}, does not
+   *         have the required length, or contains an invalid embedding row.
+   * @throws IllegalStateException Thrown if the network produces a non-finite 
score.
+   */
+  double[] score(int[] features, ContributionCache cache) {
+    ParamChecks.requireNonNullArg(features, "features");
+    if (features.length != FeedforwardContext.FEATURE_COUNT) {
+      throw new IllegalArgumentException("features must contain "
+          + FeedforwardContext.FEATURE_COUNT + " embedding indices");
+    }
+    for (int i = 0; i < features.length; i++) {
+      if (features[i] < 0 || features[i] >= embeddings.length) {
+        throw new IllegalArgumentException(
+            "feature embedding out of range at index " + i + ": " + 
features[i]);
+      }
+    }
+    final double[] hidden = new double[hiddenBias.length];
+    preActivations(features, cache, hidden);
+    for (int j = 0; j < hidden.length; j++) {
+      hidden[j] = hidden[j] * hidden[j] * hidden[j];
+    }
+    final double[] scores = new double[transitions.length];
+    outputScores(hidden, scores);
+    for (final double score : scores) {
+      if (!Double.isFinite(score)) {
+        throw new IllegalStateException("the model produced a non-finite 
transition score");
+      }
+    }
+    return scores;
+  }
+
+  /**
+   * Computes the hidden pre-activations of a configuration: the bias plus the
+   * contribution of every feature position.
+   *
+   * @param features The embedding rows of the configuration, one per position.
+   * @param cache The contribution cache to read, or {@code null} to compute 
every
+   *              contribution.
+   * @param pre Receives the pre-activations; overwritten.
+   */
+  void preActivations(int[] features, ContributionCache cache, double[] pre) {
+    for (int j = 0; j < pre.length; j++) {
+      pre[j] = hiddenBias[j];
+    }
+    for (int f = 0; f < features.length; f++) {
+      final double[] cached = cache == null ? null : cache.contribution(f, 
features[f]);
+      if (cached != null) {
+        for (int j = 0; j < pre.length; j++) {
+          pre[j] += cached[j];
+        }
+      } else {
+        addContribution(f, features[f], pre);
+      }
+    }
+  }
+
+  /**
+   * Adds the hidden-layer contribution of one embedding row at one feature 
position.
+   *
+   * @param position The feature position.
+   * @param row The embedding row at that position.
+   * @param out The hidden vector to add to.
+   */
+  void addContribution(int position, int row, double[] out) {
+    final float[] embedding = embeddings[row];
+    final int offset = position * embeddingSize;
+    for (int j = 0; j < out.length; j++) {
+      final float[] weights = hiddenWeights[j];
+      double sum = 0.0;
+      for (int d = 0; d < embeddingSize; d++) {
+        sum += (double) weights[offset + d] * embedding[d];
+      }
+      out[j] += sum;
+    }
+  }
+
+  /**
+   * Applies the output layer to hidden activations.
+   *
+   * @param hidden The hidden activations after the cube.
+   * @param scores Receives one raw score per transition; overwritten.
+   */
+  void outputScores(double[] hidden, double[] scores) {
+    for (int o = 0; o < scores.length; o++) {
+      final float[] row = outputWeights[o];
+      double sum = outputBias[o];
+      for (int j = 0; j < hidden.length; j++) {
+        sum += row[j] * hidden[j];
+      }
+      scores[o] = sum;
+    }
+  }
+
+  /**
+   * Turns finite raw scores into log-probabilities in place. The maximum is 
removed
+   * before the sum of exponentials so a large common offset does not lose the
+   * normalizer to rounding.
+   *
+   * @param scores The raw scores, overwritten with their log-softmax. Must 
not be
+   *               empty.
+   */
+  static void logSoftmaxInPlace(double[] scores) {
+    final double max = ArrayMath.max(scores);
+    for (int i = 0; i < scores.length; i++) {
+      scores[i] -= max;
+    }
+    final double logSum = ArrayMath.logSumOfExps(scores);
+    for (int i = 0; i < scores.length; i++) {
+      scores[i] -= logSum;
+    }
+  }
+
+  /**
+   * Resolves the tokens of a sentence to their word rows once, so that 
extracting the
+   * features of every configuration only indexes.
+   *
+   * @param tokens The sentence tokens. Must not contain {@code null}.
+   * @return The word row per token. Never {@code null}.
+   */
+  int[] wordRows(String[] tokens) {
+    final int[] rows = new int[tokens.length];
+    for (int i = 0; i < tokens.length; i++) {
+      rows[i] = lookup(wordIds, normalize(tokens[i]), firstWordRow);
+    }
+    return rows;
+  }
+
+  /**
+   * Resolves the tags of a sentence to their tag rows once.
+   *
+   * @param tags The part-of-speech tags. Must not contain {@code null}.
+   * @return The tag row per tag. Never {@code null}.
+   */
+  int[] tagRows(String[] tags) {
+    final int[] rows = new int[tags.length];
+    for (int i = 0; i < tags.length; i++) {
+      rows[i] = lookup(tagIds, tags[i], firstTagRow);
+    }
+    return rows;
+  }
+
+  /**
+   * @param relation The arc label of an attached dependent, or {@code null} 
for an
+   *                 absent position.
+   * @return The label row, falling back to the unknown row.
+   */
+  int labelRow(String relation) {
+    return lookup(labelIds, relation, firstLabelRow);
+  }
+
+  /**
+   * @return The word row standing for the artificial root.
+   */
+  int rootWordRow() {
+    return firstWordRow + ROOT_OFFSET;
+  }
+
+  /**
+   * @return The word row standing for an absent position.
+   */
+  int noneWordRow() {
+    return firstWordRow + NONE_OFFSET;
+  }
+
+  /**
+   * @return The tag row standing for the artificial root.
+   */
+  int rootTagRow() {
+    return firstTagRow + ROOT_OFFSET;
+  }
+
+  /**
+   * @return The tag row standing for an absent position.
+   */
+  int noneTagRow() {
+    return firstTagRow + NONE_OFFSET;
+  }
+
+  /**
+   * Resolves a symbol to its embedding row: absences map to the reserved 
absent
+   * symbol, symbols without a row of their own to the reserved unknown symbol.
+   *
+   * @param ids The vocabulary to resolve against.
+   * @param symbol The symbol to resolve, or {@code null} for an absent 
position.
+   * @param firstRow The first row of the vocabulary block.
+   * @return The embedding row of the symbol or of its fallback.
+   */
+  private static int lookup(Map<String, Integer> ids, String symbol, int 
firstRow) {
+    if (symbol == null) {
+      return firstRow + NONE_OFFSET;
+    }
+    final Integer id = ids.get(symbol);
+    return id == null ? firstRow + UNKNOWN_OFFSET : id;
+  }
+
+  /**
+   * Lowercases a word symbol with {@link 
StringUtil#toLowerCase(CharSequence)}, which
+   * maps per code point and applies the Greek final sigma rule; reserved 
symbols and
+   * absences pass through.
+   *
+   * @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 || isReservedSymbol(word)) {
+      return word;
+    }
+    return StringUtil.toLowerCase(word);
+  }
+
+  /**
+   * @param symbol The symbol to test. May be {@code null}.
+   * @return {@code true} if {@code symbol} is one of the reserved feature 
values.
+   */
+  static boolean isReservedSymbol(String symbol) {
+    // the word symbols include the tag and label symbols
+    for (final String reserved : RESERVED_WORD_SYMBOLS) {
+      if (reserved.equals(symbol)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Checks that a symbol fits the length the {@link Serializer} reads back.
+   *
+   * @param symbol The symbol to check.
+   * @param label The vocabulary name used in error messages.
+   * @throws IllegalArgumentException Thrown if the UTF-8 form of {@code 
symbol} is
+   *         longer than {@link ResourceLimits#MAX_ENTRIES} bytes.
+   */
+  static void requireStorable(String symbol, String label) {
+    // a UTF-16 unit encodes to at most three UTF-8 bytes, so short symbols 
need no encoding
+    if ((long) symbol.length() * 3 <= ResourceLimits.MAX_ENTRIES) {
+      return;
+    }
+    final long bytes = symbol.getBytes(StandardCharsets.UTF_8).length;
+    if (bytes > ResourceLimits.MAX_ENTRIES) {
+      throw new IllegalArgumentException(label + " symbol length " + bytes
+          + " exceeds the limit of " + ResourceLimits.MAX_ENTRIES + " UTF-8 
bytes");
+    }
+  }
+
+  /**
+   * @return The transition outcome strings by output index. Never {@code 
null}.
+   */
+  String[] transitions() {
+    return transitions.clone();
+  }
+
+  /**
+   * @return The decoded transitions by output index. Never {@code null}.
+   */
+  Transition[] decodedTransitions() {
+    return decodedTransitions.clone();
+  }
+
+  /**
+   * @param transition The transition to look up.
+   * @return Its output index, or {@code -1} if the inventory lacks it.
+   */
+  int transitionIndex(Transition transition) {
+    final Integer id = transitionIds.get(transition);
+    return id == null ? -1 : id;
+  }
+
+  /**
+   * Creates an independent copy of this network: the weights are deep-copied 
and the
+   * vocabularies are shared because their maps are immutable.
+   *
+   * @return A copy sharing no mutable state with this network. Never {@code 
null}.
+   */
+  FeedforwardNetwork copy() {
+    return new FeedforwardNetwork(wordIds, tagIds, labelIds, transitions,
+        embeddingSize, copyOf(embeddings), copyOf(hiddenWeights), 
hiddenBias.clone(),
+        copyOf(outputWeights), outputBias.clone());
+  }
+
+  /**
+   * Deep-copies a matrix, row by row.
+   *
+   * @param matrix The matrix to copy.
+   * @return A copy sharing no rows with {@code matrix}. Never {@code null}.
+   */
+  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;
+  }
+
+  /**
+   * @param vocabularyRows The combined word, tag, and label row count.
+   * @param transitionCount The output layer width.
+   * @param embeddingSize The embedding dimensionality.
+   * @param hiddenSize The hidden layer width.
+   * @return The number of float values a network of that shape stores.
+   */
+  static long floatValues(long vocabularyRows, long transitionCount, long 
embeddingSize,
+      long hiddenSize) {
+    final long inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+    return vocabularyRows * embeddingSize + hiddenSize * inputSize + hiddenSize
+        + transitionCount * hiddenSize + transitionCount;
+  }
+
+  /**
+   * @return The immutable map from a normalized word to its embedding row. 
Never {@code null}.
+   */
+  Map<String, Integer> wordIds() {
+    return wordIds;
+  }
+
+  /**
+   * @return The immutable map from a tag to its embedding row. Never {@code 
null}.
+   */
+  Map<String, Integer> tagIds() {
+    return tagIds;
+  }
+
+  /**
+   * @return The immutable map from an arc label to its embedding row. Never 
{@code null}.
+   */
+  Map<String, Integer> labelIds() {
+    return labelIds;
+  }
+
+  /**
+   * @return The first embedding row of the word vocabulary block.
+   */
+  int firstWordRow() {
+    return firstWordRow;
+  }
+
+  /**
+   * @return The first embedding row of the tag vocabulary block.
+   */
+  int firstTagRow() {
+    return firstTagRow;
+  }
+
+  /**
+   * @return The first embedding row of the label vocabulary block.
+   */
+  int firstLabelRow() {
+    return firstLabelRow;
+  }
+
+  /**
+   * @return The width of one embedding row.
+   */
+  int embeddingSize() {
+    return embeddingSize;
+  }
+
+  /**
+   * @return The hidden layer width.
+   */
+  int hiddenSize() {
+    return hiddenBias.length;
+  }
+
+  /**
+   * @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;
+  }
+
+  /**
+   * Reads and writes a network as a {@link FeedforwardDependencyModel} 
artifact: a
+   * four-byte magic, an {@code int} format version, the three vocabularies, 
the
+   * transition inventory, the embedding size, and the weight matrices and 
vectors.
+   * Symbols are stored as a byte length followed by UTF-8 bytes, so their 
length is
+   * not limited by {@link DataOutputStream#writeUTF}.
+   *
+   * <p>Counts read from the stream are bounded by {@link 
ResourceLimits#MAX_ENTRIES},
+   * and the float values of all matrices and vectors together by
+   * {@link ResourceLimits#MAX_MATRIX_CELLS}; matrix rows are allocated one at 
a time
+   * while reading.</p>
+   */
+  static final class Serializer implements 
ArtifactSerializer<FeedforwardNetwork> {
+
+    /** The bytes every serialized network starts with. */
+    static final byte[] MAGIC = {'F', 'F', 'D', 'P'};
+
+    /** The format version this serializer reads and writes. */
+    static final int FORMAT_VERSION = 1;
+
+    /**
+     * {@inheritDoc}
+     *
+     * @throws InvalidFormatException Thrown if the content is not a network 
in a
+     *         supported version, a count or symbol length is out of range, the
+     *         structure is inconsistent, a value is not finite, or the stream 
ends
+     *         early. Malformed UTF-8 in a symbol is replaced, not rejected.
+     */
+    @Override
+    public FeedforwardNetwork create(InputStream in) throws IOException {
+      ParamChecks.requireNonNullArg(in, "in");
+      final DataInputStream data = new DataInputStream(in);
+      try {
+        return read(data);
+      } catch (EOFException e) {
+        throw new InvalidFormatException("feedforward network ends early", e);
+      } catch (IllegalArgumentException e) {
+        throw new InvalidFormatException(e.getMessage(), e);
+      }
+    }
+
+    /**
+     * Reads the network body after any wrapping has been set up.
+     *
+     * @param data The input to read from.
+     * @return The network. Never {@code null}.
+     * @throws IOException Thrown if reading fails or the content is invalid.
+     */
+    private static FeedforwardNetwork read(DataInputStream data) throws 
IOException {
+      final byte[] magic = new byte[MAGIC.length];
+      data.readFully(magic);
+      for (int i = 0; i < magic.length; i++) {
+        if (magic[i] != MAGIC[i]) {
+          throw new InvalidFormatException("not a feedforward dependency 
network");
+        }
+      }
+      final int version = data.readInt();
+      if (version != FORMAT_VERSION) {
+        throw new InvalidFormatException("unsupported feedforward network 
version: " + version);
+      }
+      final long[] remainingEntries = {ResourceLimits.MAX_ENTRIES};
+      final Map<String, Integer> wordIds = readVocabulary(data, "word 
vocabulary",
+          remainingEntries);
+      final Map<String, Integer> tagIds = readVocabulary(data, "tag 
vocabulary",
+          remainingEntries);
+      final Map<String, Integer> labelIds = readVocabulary(data, "label 
vocabulary",
+          remainingEntries);
+      final long rows = (long) wordIds.size() + tagIds.size() + 
labelIds.size();
+      final int transitionCount = readCount(data, "transition count");
+      final String[] transitions = new String[transitionCount];
+      for (int i = 0; i < transitions.length; i++) {
+        transitions[i] = readSymbol(data, "transition");
+      }
+      final int embeddingSize = readCount(data, "embedding size");

Review Comment:
   Loader now rejects zero embedding size and an empty hidden matrix with 
InvalidFormatException. Added both to invalidNetworks.
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardNetwork.java:
##########
@@ -0,0 +1,1011 @@
+/*
+ * 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.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import opennlp.tools.ml.ArrayMath;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.model.ArtifactSerializer;
+
+/**
+ * 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, after
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The network holds every forward computation the parser and the trainer 
share:
+ * {@link #addContribution} is the per-position hidden sum, {@link 
#outputScores} the
+ * output layer, {@link #score} the complete inference pass, and
+ * {@link #logSoftmaxInPlace} the normalization of beam decoding. The trainer 
writes
+ * its updates into the live arrays the accessors return; the parser never 
modifies
+ * them, so a network that is not being trained can be shared between 
threads.</p>
+ *
+ * <p>Each vocabulary occupies one consecutive block of embedding rows and 
starts with
+ * its reserved symbols, so unknown symbols, absent positions, and the 
artificial root
+ * always have a row. Words are matched after {@link #normalize(String) 
normalization}.</p>
+ *
+ * @see FeedforwardDependencyModel
+ * @since 3.0.0
+ */
+final class FeedforwardNetwork {
+
+  /** The vocabulary key of every word, tag, or label the network has no 
embedding row for. */
+  static final String UNKNOWN = "*UNK*";
+
+  /** The reserved word symbols, in the order of their embedding rows. */
+  static final String[] RESERVED_WORD_SYMBOLS = {UNKNOWN,
+      DependencyContextGenerator.NONE_VALUE, 
DependencyContextGenerator.ROOT_VALUE};
+
+  /** The reserved tag symbols, in the order of their embedding rows. */
+  static final String[] RESERVED_TAG_SYMBOLS = {UNKNOWN,
+      DependencyContextGenerator.NONE_VALUE, 
DependencyContextGenerator.ROOT_VALUE};
+
+  /** The reserved label symbols, in the order of their embedding rows. */
+  static final String[] RESERVED_LABEL_SYMBOLS = {UNKNOWN,
+      DependencyContextGenerator.NONE_VALUE};
+
+  /** The embedding rows the reserved symbols of all three vocabularies take. 
*/
+  static final int RESERVED_ROWS = RESERVED_WORD_SYMBOLS.length
+      + RESERVED_TAG_SYMBOLS.length + RESERVED_LABEL_SYMBOLS.length;
+
+  /** The embedding row of the unknown symbol in each vocabulary block. */
+  private static final int UNKNOWN_OFFSET = 0;
+
+  /** The embedding row of the absent-position symbol in each vocabulary 
block. */
+  private static final int NONE_OFFSET = 1;
+
+  /** The embedding row of the root symbol in the word and tag blocks. */
+  private static final int ROOT_OFFSET = 2;
+
+  private final Map<String, Integer> wordIds;
+  private final Map<String, Integer> tagIds;
+  private final Map<String, Integer> labelIds;
+
+  /** The first embedding row of each vocabulary, indexed like the 
vocabularies above. */
+  private final int firstWordRow;
+  private final int firstTagRow;
+  private final int firstLabelRow;
+
+  private final String[] transitions;
+  private final Transition[] decodedTransitions;
+  private final Map<Transition, Integer> transitionIds;
+
+  private final int embeddingSize;
+  private final float[][] embeddings;
+  private final float[][] hiddenWeights;
+  private final float[] hiddenBias;
+  private final float[][] outputWeights;
+  private final float[] outputBias;
+
+  /**
+   * Assembles a network from its vocabularies and weights, copying the maps 
so later
+   * changes by the caller do not reach the network.
+   *
+   * @param wordIds The word vocabulary, mapping each normalized word to its 
row.
+   * @param tagIds The tag vocabulary.
+   * @param labelIds The dependency label vocabulary.
+   * @param transitions The transition inventory, indexed by output row.
+   * @param embeddingSize The embedding dimensionality.
+   * @param embeddings The embedding rows for words, tags, and labels.
+   * @param hiddenWeights The hidden layer weights.
+   * @param hiddenBias The hidden layer bias.
+   * @param outputWeights The output layer weights.
+   * @param outputBias The output layer bias.
+   * @throws IllegalArgumentException Thrown if a vocabulary lacks a reserved 
symbol, the
+   *         vocabularies do not partition the embedding rows into consecutive 
blocks
+   *         that start with the reserved symbols, a transition repeats or 
does not
+   *         decode, the inventory lacks SHIFT or RIGHT_ARC, or a weight 
dimension does
+   *         not match.
+   */
+  FeedforwardNetwork(Map<String, Integer> wordIds, Map<String, Integer> tagIds,
+      Map<String, Integer> 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.clone();
+    this.embeddingSize = embeddingSize;
+    this.embeddings = embeddings;
+    this.hiddenWeights = hiddenWeights;
+    this.hiddenBias = hiddenBias;
+    this.outputWeights = outputWeights;
+    this.outputBias = outputBias;
+
+    final long rows = (long) wordIds.size() + tagIds.size() + labelIds.size();
+    if (rows != embeddings.length) {
+      throw new IllegalArgumentException("embedding matrix has " + 
embeddings.length
+          + " rows for " + rows + " vocabulary entries");
+    }
+    final boolean[] taken = new boolean[embeddings.length];
+    this.firstWordRow = validateVocabulary(this.wordIds, taken, "word 
vocabulary",
+        RESERVED_WORD_SYMBOLS);
+    this.firstTagRow = validateVocabulary(this.tagIds, taken, "tag vocabulary",
+        RESERVED_TAG_SYMBOLS);
+    this.firstLabelRow = validateVocabulary(this.labelIds, taken, "label 
vocabulary",
+        RESERVED_LABEL_SYMBOLS);
+
+    this.decodedTransitions = new Transition[this.transitions.length];
+    this.transitionIds = new HashMap<>();
+    boolean hasShift = false;
+    boolean hasRightArc = false;
+    for (int i = 0; i < this.transitions.length; i++) {
+      try {
+        decodedTransitions[i] = Transition.decode(this.transitions[i]);
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException("invalid transition: " + 
this.transitions[i], e);
+      }
+      if (transitionIds.put(decodedTransitions[i], i) != null) {
+        throw new IllegalArgumentException("duplicate transition: " + 
this.transitions[i]);
+      }
+      hasShift |= decodedTransitions[i].type() == Transition.Type.SHIFT;
+      hasRightArc |= decodedTransitions[i].type() == Transition.Type.RIGHT_ARC;
+    }
+    if (!hasShift) {
+      throw new IllegalArgumentException("transition inventory has no SHIFT 
action");
+    }
+    if (!hasRightArc) {
+      throw new IllegalArgumentException("transition inventory has no 
RIGHT_ARC action");
+    }
+
+    final int inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+    requireColumns(embeddings, embeddingSize, "embedding matrix");
+    requireColumns(hiddenWeights, inputSize, "hidden matrix");
+    if (hiddenBias.length != hiddenWeights.length) {
+      throw new IllegalArgumentException("hidden bias length is " + 
hiddenBias.length
+          + ", expected " + hiddenWeights.length);
+    }
+    if (outputWeights.length != this.transitions.length) {
+      throw new IllegalArgumentException("output matrix row count is "
+          + outputWeights.length + ", expected " + this.transitions.length);
+    }
+    requireColumns(outputWeights, hiddenWeights.length, "output matrix");
+    if (outputBias.length != this.transitions.length) {
+      throw new IllegalArgumentException("output bias length is " + 
outputBias.length
+          + ", expected " + this.transitions.length);
+    }
+  }
+
+  /**
+   * Checks that one vocabulary starts with its reserved symbols and occupies 
one
+   * consecutive block of embedding rows that no other vocabulary uses.
+   *
+   * @param ids The vocabulary to check.
+   * @param taken The rows claimed so far across all vocabularies; updated in 
place.
+   * @param label The vocabulary name used in error messages.
+   * @param reserved The reserved symbols, in the order of their rows.
+   * @return The first row of the block.
+   * @throws IllegalArgumentException Thrown if the block is not consecutive, 
a row is
+   *         already taken, or a reserved symbol is missing or misplaced.
+   */
+  private static int validateVocabulary(Map<String, Integer> ids, boolean[] 
taken,
+      String label, String[] reserved) {
+    int min = Integer.MAX_VALUE;
+    int max = Integer.MIN_VALUE;
+    for (final Map.Entry<String, Integer> entry : ids.entrySet()) {
+      final int id = entry.getValue();
+      if (id < 0 || id >= taken.length) {
+        throw new IllegalArgumentException(label + " id out of range for "
+            + entry.getKey() + ": " + id);
+      }
+      if (taken[id]) {
+        throw new IllegalArgumentException("duplicate embedding id: " + id);
+      }
+      taken[id] = true;
+      min = Math.min(min, id);
+      max = Math.max(max, id);
+    }
+    if (ids.size() < reserved.length || max - min + 1 != ids.size()) {
+      throw new IllegalArgumentException(label + " must occupy one consecutive 
block of "
+          + "embedding rows that starts with its reserved symbols");
+    }
+    for (int i = 0; i < reserved.length; i++) {
+      final Integer id = ids.get(reserved[i]);
+      if (id == null || id != min + i) {
+        throw new IllegalArgumentException(label + " must have " + reserved[i]
+            + " at row " + (min + i));
+      }
+    }
+    return min;
+  }
+
+  /**
+   * @param matrix The matrix to check.
+   * @param columns The required width of every row.
+   * @param label The matrix name used in error messages.
+   * @throws IllegalArgumentException Thrown if a row has another width.
+   */
+  private static void requireColumns(float[][] matrix, int columns, String 
label) {
+    for (final float[] row : matrix) {
+      if (row.length != columns) {
+        throw new IllegalArgumentException(label + " column count is " + 
row.length
+            + ", expected " + columns);
+      }
+    }
+  }
+
+  /**
+   * Scores every transition for a configuration described by embedding rows.
+   *
+   * @param features The embedding rows of the configuration, as produced by
+   *                 {@link FeedforwardContext#featureIds}. Must not be {@code 
null}.
+   * @param cache The contribution cache to read, or {@code null} to compute 
every
+   *              contribution.
+   * @return One unnormalized score per transition, indexed like
+   *         {@link #transitions()}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code features} is {@code 
null}, does not
+   *         have the required length, or contains an invalid embedding row.
+   * @throws IllegalStateException Thrown if the network produces a non-finite 
score.
+   */
+  double[] score(int[] features, ContributionCache cache) {
+    ParamChecks.requireNonNullArg(features, "features");
+    if (features.length != FeedforwardContext.FEATURE_COUNT) {
+      throw new IllegalArgumentException("features must contain "
+          + FeedforwardContext.FEATURE_COUNT + " embedding indices");
+    }
+    for (int i = 0; i < features.length; i++) {
+      if (features[i] < 0 || features[i] >= embeddings.length) {
+        throw new IllegalArgumentException(
+            "feature embedding out of range at index " + i + ": " + 
features[i]);
+      }
+    }
+    final double[] hidden = new double[hiddenBias.length];
+    preActivations(features, cache, hidden);
+    for (int j = 0; j < hidden.length; j++) {
+      hidden[j] = hidden[j] * hidden[j] * hidden[j];
+    }
+    final double[] scores = new double[transitions.length];
+    outputScores(hidden, scores);
+    for (final double score : scores) {
+      if (!Double.isFinite(score)) {
+        throw new IllegalStateException("the model produced a non-finite 
transition score");
+      }
+    }
+    return scores;
+  }
+
+  /**
+   * Computes the hidden pre-activations of a configuration: the bias plus the
+   * contribution of every feature position.
+   *
+   * @param features The embedding rows of the configuration, one per position.
+   * @param cache The contribution cache to read, or {@code null} to compute 
every
+   *              contribution.
+   * @param pre Receives the pre-activations; overwritten.
+   */
+  void preActivations(int[] features, ContributionCache cache, double[] pre) {
+    for (int j = 0; j < pre.length; j++) {
+      pre[j] = hiddenBias[j];
+    }
+    for (int f = 0; f < features.length; f++) {
+      final double[] cached = cache == null ? null : cache.contribution(f, 
features[f]);
+      if (cached != null) {
+        for (int j = 0; j < pre.length; j++) {
+          pre[j] += cached[j];
+        }
+      } else {
+        addContribution(f, features[f], pre);
+      }
+    }
+  }
+
+  /**
+   * Adds the hidden-layer contribution of one embedding row at one feature 
position.
+   *
+   * @param position The feature position.
+   * @param row The embedding row at that position.
+   * @param out The hidden vector to add to.
+   */
+  void addContribution(int position, int row, double[] out) {
+    final float[] embedding = embeddings[row];
+    final int offset = position * embeddingSize;
+    for (int j = 0; j < out.length; j++) {
+      final float[] weights = hiddenWeights[j];
+      double sum = 0.0;
+      for (int d = 0; d < embeddingSize; d++) {
+        sum += (double) weights[offset + d] * embedding[d];
+      }
+      out[j] += sum;
+    }
+  }
+
+  /**
+   * Applies the output layer to hidden activations.
+   *
+   * @param hidden The hidden activations after the cube.
+   * @param scores Receives one raw score per transition; overwritten.
+   */
+  void outputScores(double[] hidden, double[] scores) {
+    for (int o = 0; o < scores.length; o++) {
+      final float[] row = outputWeights[o];
+      double sum = outputBias[o];
+      for (int j = 0; j < hidden.length; j++) {
+        sum += row[j] * hidden[j];
+      }
+      scores[o] = sum;
+    }
+  }
+
+  /**
+   * Turns finite raw scores into log-probabilities in place. The maximum is 
removed
+   * before the sum of exponentials so a large common offset does not lose the
+   * normalizer to rounding.
+   *
+   * @param scores The raw scores, overwritten with their log-softmax. Must 
not be
+   *               empty.
+   */
+  static void logSoftmaxInPlace(double[] scores) {
+    final double max = ArrayMath.max(scores);
+    for (int i = 0; i < scores.length; i++) {
+      scores[i] -= max;
+    }
+    final double logSum = ArrayMath.logSumOfExps(scores);
+    for (int i = 0; i < scores.length; i++) {
+      scores[i] -= logSum;
+    }
+  }
+
+  /**
+   * Resolves the tokens of a sentence to their word rows once, so that 
extracting the
+   * features of every configuration only indexes.
+   *
+   * @param tokens The sentence tokens. Must not contain {@code null}.
+   * @return The word row per token. Never {@code null}.
+   */
+  int[] wordRows(String[] tokens) {
+    final int[] rows = new int[tokens.length];
+    for (int i = 0; i < tokens.length; i++) {
+      rows[i] = lookup(wordIds, normalize(tokens[i]), firstWordRow);
+    }
+    return rows;
+  }
+
+  /**
+   * Resolves the tags of a sentence to their tag rows once.
+   *
+   * @param tags The part-of-speech tags. Must not contain {@code null}.
+   * @return The tag row per tag. Never {@code null}.
+   */
+  int[] tagRows(String[] tags) {
+    final int[] rows = new int[tags.length];
+    for (int i = 0; i < tags.length; i++) {
+      rows[i] = lookup(tagIds, tags[i], firstTagRow);
+    }
+    return rows;
+  }
+
+  /**
+   * @param relation The arc label of an attached dependent, or {@code null} 
for an
+   *                 absent position.
+   * @return The label row, falling back to the unknown row.
+   */
+  int labelRow(String relation) {
+    return lookup(labelIds, relation, firstLabelRow);
+  }
+
+  /**
+   * @return The word row standing for the artificial root.
+   */
+  int rootWordRow() {
+    return firstWordRow + ROOT_OFFSET;
+  }
+
+  /**
+   * @return The word row standing for an absent position.
+   */
+  int noneWordRow() {
+    return firstWordRow + NONE_OFFSET;
+  }
+
+  /**
+   * @return The tag row standing for the artificial root.
+   */
+  int rootTagRow() {
+    return firstTagRow + ROOT_OFFSET;
+  }
+
+  /**
+   * @return The tag row standing for an absent position.
+   */
+  int noneTagRow() {
+    return firstTagRow + NONE_OFFSET;
+  }
+
+  /**
+   * Resolves a symbol to its embedding row: absences map to the reserved 
absent
+   * symbol, symbols without a row of their own to the reserved unknown symbol.
+   *
+   * @param ids The vocabulary to resolve against.
+   * @param symbol The symbol to resolve, or {@code null} for an absent 
position.
+   * @param firstRow The first row of the vocabulary block.
+   * @return The embedding row of the symbol or of its fallback.
+   */
+  private static int lookup(Map<String, Integer> ids, String symbol, int 
firstRow) {
+    if (symbol == null) {
+      return firstRow + NONE_OFFSET;
+    }
+    final Integer id = ids.get(symbol);
+    return id == null ? firstRow + UNKNOWN_OFFSET : id;
+  }
+
+  /**
+   * Lowercases a word symbol with {@link 
StringUtil#toLowerCase(CharSequence)}, which
+   * maps per code point and applies the Greek final sigma rule; reserved 
symbols and
+   * absences pass through.
+   *
+   * @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 || isReservedSymbol(word)) {
+      return word;
+    }
+    return StringUtil.toLowerCase(word);
+  }
+
+  /**
+   * @param symbol The symbol to test. May be {@code null}.
+   * @return {@code true} if {@code symbol} is one of the reserved feature 
values.
+   */
+  static boolean isReservedSymbol(String symbol) {
+    // the word symbols include the tag and label symbols
+    for (final String reserved : RESERVED_WORD_SYMBOLS) {
+      if (reserved.equals(symbol)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Checks that a symbol fits the length the {@link Serializer} reads back.
+   *
+   * @param symbol The symbol to check.
+   * @param label The vocabulary name used in error messages.
+   * @throws IllegalArgumentException Thrown if the UTF-8 form of {@code 
symbol} is
+   *         longer than {@link ResourceLimits#MAX_ENTRIES} bytes.
+   */
+  static void requireStorable(String symbol, String label) {
+    // a UTF-16 unit encodes to at most three UTF-8 bytes, so short symbols 
need no encoding
+    if ((long) symbol.length() * 3 <= ResourceLimits.MAX_ENTRIES) {
+      return;
+    }
+    final long bytes = symbol.getBytes(StandardCharsets.UTF_8).length;
+    if (bytes > ResourceLimits.MAX_ENTRIES) {
+      throw new IllegalArgumentException(label + " symbol length " + bytes
+          + " exceeds the limit of " + ResourceLimits.MAX_ENTRIES + " UTF-8 
bytes");
+    }
+  }
+
+  /**
+   * @return The transition outcome strings by output index. Never {@code 
null}.
+   */
+  String[] transitions() {
+    return transitions.clone();
+  }
+
+  /**
+   * @return The decoded transitions by output index. Never {@code null}.
+   */
+  Transition[] decodedTransitions() {
+    return decodedTransitions.clone();
+  }
+
+  /**
+   * @param transition The transition to look up.
+   * @return Its output index, or {@code -1} if the inventory lacks it.
+   */
+  int transitionIndex(Transition transition) {
+    final Integer id = transitionIds.get(transition);
+    return id == null ? -1 : id;
+  }
+
+  /**
+   * Creates an independent copy of this network: the weights are deep-copied 
and the
+   * vocabularies are shared because their maps are immutable.
+   *
+   * @return A copy sharing no mutable state with this network. Never {@code 
null}.
+   */
+  FeedforwardNetwork copy() {
+    return new FeedforwardNetwork(wordIds, tagIds, labelIds, transitions,
+        embeddingSize, copyOf(embeddings), copyOf(hiddenWeights), 
hiddenBias.clone(),
+        copyOf(outputWeights), outputBias.clone());
+  }
+
+  /**
+   * Deep-copies a matrix, row by row.
+   *
+   * @param matrix The matrix to copy.
+   * @return A copy sharing no rows with {@code matrix}. Never {@code null}.
+   */
+  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;
+  }
+
+  /**
+   * @param vocabularyRows The combined word, tag, and label row count.
+   * @param transitionCount The output layer width.
+   * @param embeddingSize The embedding dimensionality.
+   * @param hiddenSize The hidden layer width.
+   * @return The number of float values a network of that shape stores.
+   */
+  static long floatValues(long vocabularyRows, long transitionCount, long 
embeddingSize,
+      long hiddenSize) {
+    final long inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+    return vocabularyRows * embeddingSize + hiddenSize * inputSize + hiddenSize
+        + transitionCount * hiddenSize + transitionCount;
+  }
+
+  /**
+   * @return The immutable map from a normalized word to its embedding row. 
Never {@code null}.
+   */
+  Map<String, Integer> wordIds() {
+    return wordIds;
+  }
+
+  /**
+   * @return The immutable map from a tag to its embedding row. Never {@code 
null}.
+   */
+  Map<String, Integer> tagIds() {
+    return tagIds;
+  }
+
+  /**
+   * @return The immutable map from an arc label to its embedding row. Never 
{@code null}.
+   */
+  Map<String, Integer> labelIds() {
+    return labelIds;
+  }
+
+  /**
+   * @return The first embedding row of the word vocabulary block.
+   */
+  int firstWordRow() {
+    return firstWordRow;
+  }
+
+  /**
+   * @return The first embedding row of the tag vocabulary block.
+   */
+  int firstTagRow() {
+    return firstTagRow;
+  }
+
+  /**
+   * @return The first embedding row of the label vocabulary block.
+   */
+  int firstLabelRow() {
+    return firstLabelRow;
+  }
+
+  /**
+   * @return The width of one embedding row.
+   */
+  int embeddingSize() {
+    return embeddingSize;
+  }
+
+  /**
+   * @return The hidden layer width.
+   */
+  int hiddenSize() {
+    return hiddenBias.length;
+  }
+
+  /**
+   * @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;
+  }
+
+  /**
+   * Reads and writes a network as a {@link FeedforwardDependencyModel} 
artifact: a
+   * four-byte magic, an {@code int} format version, the three vocabularies, 
the
+   * transition inventory, the embedding size, and the weight matrices and 
vectors.
+   * Symbols are stored as a byte length followed by UTF-8 bytes, so their 
length is
+   * not limited by {@link DataOutputStream#writeUTF}.
+   *
+   * <p>Counts read from the stream are bounded by {@link 
ResourceLimits#MAX_ENTRIES},
+   * and the float values of all matrices and vectors together by
+   * {@link ResourceLimits#MAX_MATRIX_CELLS}; matrix rows are allocated one at 
a time
+   * while reading.</p>
+   */
+  static final class Serializer implements 
ArtifactSerializer<FeedforwardNetwork> {
+
+    /** The bytes every serialized network starts with. */
+    static final byte[] MAGIC = {'F', 'F', 'D', 'P'};
+
+    /** The format version this serializer reads and writes. */
+    static final int FORMAT_VERSION = 1;
+
+    /**
+     * {@inheritDoc}
+     *
+     * @throws InvalidFormatException Thrown if the content is not a network 
in a
+     *         supported version, a count or symbol length is out of range, the
+     *         structure is inconsistent, a value is not finite, or the stream 
ends
+     *         early. Malformed UTF-8 in a symbol is replaced, not rejected.
+     */
+    @Override
+    public FeedforwardNetwork create(InputStream in) throws IOException {
+      ParamChecks.requireNonNullArg(in, "in");
+      final DataInputStream data = new DataInputStream(in);
+      try {
+        return read(data);
+      } catch (EOFException e) {
+        throw new InvalidFormatException("feedforward network ends early", e);
+      } catch (IllegalArgumentException e) {
+        throw new InvalidFormatException(e.getMessage(), e);
+      }
+    }
+
+    /**
+     * Reads the network body after any wrapping has been set up.
+     *
+     * @param data The input to read from.
+     * @return The network. Never {@code null}.
+     * @throws IOException Thrown if reading fails or the content is invalid.
+     */
+    private static FeedforwardNetwork read(DataInputStream data) throws 
IOException {
+      final byte[] magic = new byte[MAGIC.length];
+      data.readFully(magic);
+      for (int i = 0; i < magic.length; i++) {
+        if (magic[i] != MAGIC[i]) {
+          throw new InvalidFormatException("not a feedforward dependency 
network");
+        }
+      }
+      final int version = data.readInt();
+      if (version != FORMAT_VERSION) {
+        throw new InvalidFormatException("unsupported feedforward network 
version: " + version);
+      }
+      final long[] remainingEntries = {ResourceLimits.MAX_ENTRIES};
+      final Map<String, Integer> wordIds = readVocabulary(data, "word 
vocabulary",
+          remainingEntries);
+      final Map<String, Integer> tagIds = readVocabulary(data, "tag 
vocabulary",
+          remainingEntries);
+      final Map<String, Integer> labelIds = readVocabulary(data, "label 
vocabulary",
+          remainingEntries);
+      final long rows = (long) wordIds.size() + tagIds.size() + 
labelIds.size();
+      final int transitionCount = readCount(data, "transition count");
+      final String[] transitions = new String[transitionCount];

Review Comment:
   The transitions now go into a list as they are read.
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1123 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: 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
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The hyperparameters are read from {@link TrainingParameters}: the keys 
of this
+ * class plus {@link Parameters#ITERATIONS_PARAM} for the number of epochs,
+ * {@link Parameters#CUTOFF_PARAM} for the minimum word frequency, and
+ * {@link BeamSearch#BEAM_SIZE_PARAMETER} for the refinement beam. Other keys 
are
+ * ignored.</p>
+ *
+ * <p>Words below the frequency cutoff share the reserved unknown row, which is
+ * therefore trained only when such words exist; the unknown rows of the tag 
and label
+ * vocabularies keep their random initialization. Absent template positions 
share a
+ * trained padding row. Non-projective samples have no arc-standard derivation 
and are
+ * skipped. Training is deterministic for a fixed {@link #SEED_PARAM}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+  /** The training parameter key of the embedding dimensionality; default 
{@value #DEFAULT_EMBEDDING_SIZE}. */
+  public static final String EMBEDDING_SIZE_PARAM = "EmbeddingSize";
+
+  /** The training parameter key of the hidden layer width; default {@value 
#DEFAULT_HIDDEN_SIZE}. */
+  public static final String HIDDEN_SIZE_PARAM = "HiddenSize";
+
+  /** The training parameter key of the minibatch size; default {@value 
#DEFAULT_BATCH_SIZE}. */
+  public static final String BATCH_SIZE_PARAM = "BatchSize";
+
+  /** The training parameter key of the AdaGrad step size; default {@value 
#DEFAULT_LEARNING_RATE}. */
+  public static final String LEARNING_RATE_PARAM = "LearningRate";
+
+  /** The training parameter key of the L2 penalty on dense weights; default 
{@value #DEFAULT_L2}. */
+  public static final String L2_PARAM = "L2";
+
+  /** The training parameter key of the hidden dropout probability; default 
{@value #DEFAULT_DROPOUT}. */
+  public static final String DROPOUT_PARAM = "Dropout";
+
+  /**
+   * The training parameter key of the random seed, read as an {@code int}; 
default
+   * {@value #DEFAULT_SEED}.
+   */
+  public static final String SEED_PARAM = "Seed";
+
+  /** The default embedding dimensionality. */
+  public static final int DEFAULT_EMBEDDING_SIZE = 50;
+
+  /** The default hidden layer width. */
+  public static final int DEFAULT_HIDDEN_SIZE = 200;
+
+  /** The default number of epochs. */
+  public static final int DEFAULT_EPOCHS = 10;
+
+  /** The default minibatch size. */
+  public static final int DEFAULT_BATCH_SIZE = 256;
+
+  /** The default AdaGrad step size. */
+  public static final double DEFAULT_LEARNING_RATE = 0.02;
+
+  /** The default L2 penalty. */
+  public static final double DEFAULT_L2 = 1e-8;
+
+  /** The default hidden dropout probability. */
+  public static final double DEFAULT_DROPOUT = 0.5;
+
+  /** The default minimum word frequency for an embedding row. */
+  public static final int DEFAULT_WORD_CUTOFF = 2;
+
+  /** The default random seed. */
+  public static final int DEFAULT_SEED = 17;
+
+  /** The default refinement beam size. */
+  public static final int DEFAULT_BEAM_SIZE = 4;
+
+  /** The maximum embedding width. */
+  static final int MAX_EMBEDDING_SIZE = 4_096;
+
+  /** The maximum hidden-layer width. */
+  static final int MAX_HIDDEN_SIZE = 65_536;
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+  /** The AdaGrad denominator offset that keeps the first steps finite. */
+  private static final double ADAGRAD_EPSILON = 1e-6;
+
+  /** The derivative factor of the cube activation, {@code d/dx x^3 = 3x^2}. */
+  private static final double CUBE_DERIVATIVE_FACTOR = 3.0;
+
+  /** The numerator of the Glorot uniform bound, {@code sqrt(6 / (fanIn + 
fanOut))}. */
+  private static final double GLOROT_NUMERATOR = 6.0;
+
+  /** The half-width of the uniform range embeddings are drawn from. */
+  private static final double EMBEDDING_INIT_SCALE = 0.01;
+
+  /** The smallest probability the reported training loss takes the log of. */
+  private static final double LOSS_PROBABILITY_FLOOR = 1e-12;
+
+  /** SHIFT and at least one RIGHT_ARC are required to parse a sentence. */
+  private static final int MIN_TRANSITIONS = 2;
+
+  /** The smallest beam that can lose the gold derivation to another path. */
+  private static final int MIN_REFINE_BEAM_SIZE = 2;
+
+  /** Prevents construction of this utility class. */
+  private FeedforwardDependencyTrainer() {
+  }
+
+  /**
+   * The parsed hyperparameters of one training or refinement run.
+   *
+   * @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.
+   * @param beamSize The refinement beam width; {@link #refine} requires at 
least 2.
+   */
+  record Settings(int embeddingSize, int hiddenSize, int epochs, int batchSize,
+      double learningRate, double l2, double dropout, int wordCutoff, long 
seed,
+      int beamSize) {
+
+    /**
+     * Validates the hyperparameters.
+     *
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range or the smallest network of this shape exceeds
+     *         {@link ResourceLimits#MAX_MATRIX_CELLS} float values.
+     */
+    Settings {
+      ParamChecks.requirePositive(embeddingSize, EMBEDDING_SIZE_PARAM);
+      ParamChecks.requirePositive(hiddenSize, HIDDEN_SIZE_PARAM);
+      ParamChecks.requirePositive(epochs, Parameters.ITERATIONS_PARAM);
+      ParamChecks.requirePositive(batchSize, BATCH_SIZE_PARAM);
+      if (embeddingSize > MAX_EMBEDDING_SIZE) {
+        throw new IllegalArgumentException(EMBEDDING_SIZE_PARAM + " exceeds " 
+ MAX_EMBEDDING_SIZE
+            + ": " + embeddingSize);
+      }
+      if (hiddenSize > MAX_HIDDEN_SIZE) {
+        throw new IllegalArgumentException(HIDDEN_SIZE_PARAM + " exceeds " + 
MAX_HIDDEN_SIZE
+            + ": " + hiddenSize);
+      }
+      requireWithinCellLimit(FeedforwardNetwork.RESERVED_ROWS, MIN_TRANSITIONS,
+          embeddingSize, hiddenSize);
+      ParamChecks.requirePositive(learningRate, LEARNING_RATE_PARAM);
+      ParamChecks.requireNonNegative(ParamChecks.requireFinite(l2, L2_PARAM), 
L2_PARAM);
+      if (!(dropout >= 0.0 && dropout < 1.0)) {
+        throw new IllegalArgumentException(DROPOUT_PARAM + " must be in [0, 
1): " + dropout);
+      }
+      ParamChecks.requireNonNegative(wordCutoff, Parameters.CUTOFF_PARAM);
+    }
+
+    /**
+     * Reads the hyperparameters, substituting the defaults for absent keys.
+     *
+     * @param parameters The training parameters. Must not be {@code null}.
+     * @return The parsed settings. Never {@code null}.
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range.
+     */
+    static Settings from(TrainingParameters parameters) {
+      return new Settings(
+          parameters.getIntParameter(EMBEDDING_SIZE_PARAM, 
DEFAULT_EMBEDDING_SIZE),
+          parameters.getIntParameter(HIDDEN_SIZE_PARAM, DEFAULT_HIDDEN_SIZE),
+          parameters.getIntParameter(Parameters.ITERATIONS_PARAM, 
DEFAULT_EPOCHS),
+          parameters.getIntParameter(BATCH_SIZE_PARAM, DEFAULT_BATCH_SIZE),
+          parameters.getDoubleParameter(LEARNING_RATE_PARAM, 
DEFAULT_LEARNING_RATE),
+          parameters.getDoubleParameter(L2_PARAM, DEFAULT_L2),
+          parameters.getDoubleParameter(DROPOUT_PARAM, DEFAULT_DROPOUT),
+          parameters.getIntParameter(Parameters.CUTOFF_PARAM, 
DEFAULT_WORD_CUTOFF),
+          parameters.getIntParameter(SEED_PARAM, DEFAULT_SEED),
+          parameters.getIntParameter(BeamSearch.BEAM_SIZE_PARAMETER, 
DEFAULT_BEAM_SIZE));
+    }
+  }
+
+  /**
+   * Trains a model from dependency samples.
+   *
+   * @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}.
+   * @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}, a
+   *         hyperparameter is out of range, no trainable example can be 
derived from
+   *         the samples, a vocabulary exceeds {@link 
ResourceLimits#MAX_ENTRIES}
+   *         entries or a symbol as many UTF-8 bytes, or the network would 
store more
+   *         than {@link ResourceLimits#MAX_MATRIX_CELLS} values.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters)
+      throws IOException {
+    return train(languageCode, samples, parameters, null);
+  }
+
+  /**
+   * Trains a model from dependency samples, seeding word embeddings from a 
pretrained
+   * source.
+   *
+   * <p>The provider is consulted once per vocabulary word during 
initialization. Words
+   * it returns {@code null} for keep their random initialization. All 
embeddings remain
+   * trainable and are stored in the model.</p>
+   *
+   * @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}.
+   * @param pretrained Maps a normalized word to its pretrained vector of 
exactly
+   *                   {@link #EMBEDDING_SIZE_PARAM} 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 languageCode}, {@code 
samples},
+   *         or {@code parameters} is {@code null}, a hyperparameter is out of 
range, no
+   *         trainable example can be derived, a vocabulary exceeds
+   *         {@link ResourceLimits#MAX_ENTRIES} entries or a symbol as many 
UTF-8 bytes,
+   *         the network would store more than {@link 
ResourceLimits#MAX_MATRIX_CELLS}
+   *         values, or a pretrained vector has the wrong dimensionality or a 
non-finite
+   *         value.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters,
+      Function<String, float[]> pretrained) throws IOException {
+    ParamChecks.requireNonNullArg(languageCode, "languageCode");
+    ParamChecks.requireNonNullArg(samples, "samples");
+    ParamChecks.requireNonNullArg(parameters, "parameters");
+    final Settings settings = Settings.from(parameters);
+    final List<Derivation> derivations = derive(samples);
+    if (derivations.isEmpty()) {
+      throw new IllegalArgumentException("no trainable examples in the 
samples");
+    }
+    final FeedforwardNetwork network = initialize(derivations, settings);
+    if (pretrained != null) {
+      seed(network, pretrained, settings);
+    }
+    int exampleCount = 0;
+    for (final Derivation derivation : derivations) {
+      exampleCount += derivation.oracle().size();
+    }
+    final int[][] features = new int[exampleCount][];
+    final int[] gold = new int[exampleCount];
+    collectExamples(derivations, network, features, gold);
+    optimize(network, features, gold, settings);
+    return new FeedforwardDependencyModel(languageCode, network, 
manifestEntries(settings));
+  }
+
+  /**
+   * Refines a locally trained model with beam search and the early-update 
method of
+   * <a href="https://aclanthology.org/P04-1015/";>Collins and Roark 
(2004)</a>. The loss
+   * is conditional likelihood over candidate paths scored by summed 
log-probabilities.
+   *
+   * <p>Refinement trains a copy with per-sentence AdaGrad steps and no 
dropout.
+   * {@link Parameters#ITERATIONS_PARAM} sets the number of passes and
+   * {@link BeamSearch#BEAM_SIZE_PARAMETER} the beam width, which is also the 
beam size
+   * to parse the result with. The transition inventory of {@code model} is not
+   * extended.</p>
+   *
+   * @param model The locally trained model. Not modified. Must not be {@code 
null}.
+   * @param samples The training samples. Must not be {@code null}.
+   * @param parameters The {@link TrainingParameters}; the epochs, learning 
rate, L2
+   *                   penalty, seed, and beam size apply. Must not be {@code 
null}.
+   * @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}, a
+   *         hyperparameter is out of range, the beam size is below 2, no 
trainable
+   *         sample can be derived, or a sample requires a transition {@code 
model}
+   *         does not know.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel refine(FeedforwardDependencyModel 
model,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters)
+      throws IOException {
+    ParamChecks.requireNonNullArg(model, "model");
+    ParamChecks.requireNonNullArg(samples, "samples");
+    ParamChecks.requireNonNullArg(parameters, "parameters");
+    final Settings settings = Settings.from(parameters);
+    if (settings.beamSize() < MIN_REFINE_BEAM_SIZE) {
+      throw new IllegalArgumentException(BeamSearch.BEAM_SIZE_PARAMETER
+          + " must be at least " + MIN_REFINE_BEAM_SIZE + ": " + 
settings.beamSize());
+    }
+    final FeedforwardNetwork refined = model.network().copy();
+    final List<Derivation> derivations = derive(samples);
+    if (derivations.isEmpty()) {
+      throw new IllegalArgumentException("no trainable samples for 
refinement");
+    }
+    final int[][] oracles = new int[derivations.size()][];
+    for (int d = 0; d < oracles.length; d++) {
+      final List<Transition> oracle = derivations.get(d).oracle();
+      oracles[d] = new int[oracle.size()];
+      for (int i = 0; i < oracles[d].length; i++) {
+        final int id = refined.transitionIndex(oracle.get(i));
+        if (id < 0) {
+          throw new IllegalArgumentException(
+              "unknown transition in the refinement samples: " + 
oracle.get(i).encode());
+        }
+        oracles[d][i] = id;
+      }
+    }
+
+    final Optimizer optimizer = new Optimizer(refined, settings);
+    final Transition[] transitions = refined.decodedTransitions();
+    final Random random = new Random(settings.seed());
+    final int[] order = identity(derivations.size());
+    for (int epoch = 1; epoch <= settings.epochs(); epoch++) {
+      final long epochStart = System.currentTimeMillis();
+      shuffle(order, random);
+      double loss = 0.0;
+      for (final int index : order) {
+        loss += optimizer.refineSentence(derivations.get(index).sample(), 
oracles[index],
+            transitions, settings.beamSize());
+      }
+      checkFinite(refined);
+      logger.info("refine epoch {}: loss {} over {} sentences in {} ms", epoch,
+          loss / order.length, order.length, System.currentTimeMillis() - 
epochStart);
+    }
+    final Map<String, String> manifestEntries = manifestEntries(settings);
+    manifestEntries.put(BeamSearch.BEAM_SIZE_PARAMETER, 
Integer.toString(settings.beamSize()));
+    return new FeedforwardDependencyModel(model.getLanguage(), refined, 
manifestEntries);
+  }
+
+  /**
+   * @param settings The hyperparameters of the run.
+   * @return The manifest entries recording the run. Never {@code null}.
+   */
+  private static Map<String, String> manifestEntries(Settings settings) {
+    final Map<String, String> entries = new HashMap<>();
+    entries.put(BaseModel.TRAINING_ITERATIONS_PROPERTY, 
Integer.toString(settings.epochs()));
+    entries.put(BaseModel.TRAINING_CUTOFF_PROPERTY, 
Integer.toString(settings.wordCutoff()));
+    return entries;
+  }
+
+  /**
+   * A projective training sample with its arc-standard oracle derivation.
+   *
+   * @param sample The sample.
+   * @param oracle The gold transitions, in order.
+   */
+  private record Derivation(DependencySample sample, List<Transition> oracle) {
+  }
+
+  /**
+   * Reads a sample stream into memory and derives the oracle of every 
projective
+   * sample once; the trainer passes over the derivations repeatedly.
+   *
+   * @param samples The stream to drain.
+   * @return The derivations in stream order, without the non-projective 
samples.
+   *         Never {@code null}.
+   * @throws IOException Thrown if reading the samples fails.
+   */
+  private static List<Derivation> derive(ObjectStream<DependencySample> 
samples)
+      throws IOException {
+    final List<Derivation> derivations = new ArrayList<>();
+    int skipped = 0;
+    DependencySample sample;
+    while ((sample = samples.read()) != null) {
+      if (ArcStandardOracle.isProjective(sample.getTree())) {
+        derivations.add(new Derivation(sample, 
ArcStandardOracle.transitions(sample.getTree())));
+      } else {
+        skipped++;
+      }
+    }
+    if (skipped > 0) {
+      logger.warn("Skipped {} non-projective sample(s) without an arc-standard 
derivation.",
+          skipped);
+    }
+    return derivations;
+  }
+
+  /**
+   * Overwrites the random word rows with pretrained vectors where available.
+   *
+   * @param network The freshly initialized network whose word rows are seeded.
+   * @param pretrained The pretrained vector source, returning {@code null} 
for a word it
+   *                   does not cover.
+   * @param settings The hyperparameters, which fix the expected vector width.
+   * @throws IllegalArgumentException Thrown if a pretrained vector has a 
different width
+   *         than the embedding size or contains a non-finite value.
+   */
+  private static void seed(FeedforwardNetwork network,
+      Function<String, float[]> pretrained, Settings settings) {
+    int seeded = 0;
+    for (final Map.Entry<String, Integer> entry : 
network.wordIds().entrySet()) {
+      if (FeedforwardNetwork.isReservedSymbol(entry.getKey())) {
+        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());
+      }
+      for (final float value : vector) {
+        if (!Float.isFinite(value)) {
+          throw new IllegalArgumentException(
+              "pretrained vector for '" + entry.getKey() + "' contains a 
non-finite value");
+        }
+      }
+      System.arraycopy(vector, 0, network.embeddings()[entry.getValue()], 0, 
vector.length);
+      seeded++;
+    }
+    logger.info("seeded {} of {} word embeddings from the pretrained source", 
seeded,
+        network.wordIds().size());
+  }
+
+  /**
+   * Builds the vocabularies and randomly initialized weights. Symbols are 
assigned
+   * rows in sorted order after the reserved symbols, so the network depends 
only on
+   * the samples and the settings. A word, tag, or label in the training data 
that
+   * spells a reserved symbol shares the reserved row, as an unknown symbol 
does.
+   *
+   * @param derivations The projective training samples with their oracles.
+   * @param settings The hyperparameters.
+   * @return The untrained network. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if a vocabulary or the transition 
inventory
+   *         exceeds {@link ResourceLimits#MAX_ENTRIES} entries, a symbol 
exceeds as many
+   *         UTF-8 bytes, or the network would store more than
+   *         {@link ResourceLimits#MAX_MATRIX_CELLS} values.
+   */
+  private static FeedforwardNetwork initialize(List<Derivation> derivations,
+      Settings settings) {
+    final Map<String, Integer> wordCounts = new HashMap<>();
+    final Set<String> tagSet = new HashSet<>();
+    final Set<String> labelSet = new HashSet<>();
+    final Set<String> transitionSet = new HashSet<>();
+    for (final Derivation derivation : derivations) {
+      final DependencySample s = derivation.sample();
+      for (final String token : s.getTokens()) {
+        wordCounts.merge(FeedforwardNetwork.normalize(token), 1, Integer::sum);
+      }
+      for (final String tag : s.getTags()) {
+        tagSet.add(tag);
+      }
+      final DependencyTree tree = s.getTree();
+      for (int i = 0; i < tree.size(); i++) {
+        labelSet.add(tree.relationOf(i));
+      }
+      for (final Transition transition : derivation.oracle()) {
+        transitionSet.add(transition.encode());
+      }
+    }
+    final List<String> words = new ArrayList<>();
+    for (final Map.Entry<String, Integer> entry : wordCounts.entrySet()) {
+      if (entry.getValue() >= settings.wordCutoff()) {
+        words.add(entry.getKey());
+      }
+    }
+
+    int row = 0;
+    final Map<String, Integer> wordIds = new HashMap<>();
+    row = assignRows(wordIds, row, FeedforwardNetwork.RESERVED_WORD_SYMBOLS, 
words,
+        "word vocabulary");
+    final Map<String, Integer> tagIds = new HashMap<>();
+    row = assignRows(tagIds, row, FeedforwardNetwork.RESERVED_TAG_SYMBOLS,
+        new ArrayList<>(tagSet), "tag vocabulary");
+    final Map<String, Integer> labelIds = new HashMap<>();
+    row = assignRows(labelIds, row, FeedforwardNetwork.RESERVED_LABEL_SYMBOLS,
+        new ArrayList<>(labelSet), "label vocabulary");
+
+    final String[] transitions = transitionSet.toArray(String[]::new);
+    Arrays.sort(transitions);
+    ResourceLimits.requireWithinMaxEntries(transitions.length, "transition 
count");
+    for (final String transition : transitions) {
+      FeedforwardNetwork.requireStorable(transition, "transition");
+    }
+    requireWithinCellLimit(row, transitions.length, settings.embeddingSize(),
+        settings.hiddenSize());
+
+    final Random random = new Random(settings.seed());
+    final int inputSize = FeedforwardContext.FEATURE_COUNT * 
settings.embeddingSize();
+    final float[][] embeddings = uniform(random, row, settings.embeddingSize(),
+        EMBEDDING_INIT_SCALE);
+    final float[][] hiddenWeights = uniform(random, settings.hiddenSize(), 
inputSize,
+        Math.sqrt(GLOROT_NUMERATOR / (inputSize + settings.hiddenSize())));
+    final float[][] outputWeights = uniform(random, transitions.length,
+        settings.hiddenSize(),
+        Math.sqrt(GLOROT_NUMERATOR / (settings.hiddenSize() + 
transitions.length)));
+    return new FeedforwardNetwork(wordIds, tagIds, labelIds, transitions,
+        settings.embeddingSize(), embeddings, hiddenWeights,
+        new float[settings.hiddenSize()], outputWeights, new 
float[transitions.length]);
+  }
+
+  /**
+   * Assigns the next embedding rows of one vocabulary: the reserved symbols 
first, then
+   * the observed symbols in sorted order, skipping observed symbols that 
spell a
+   * reserved one.
+   *
+   * @param ids The vocabulary to fill.
+   * @param row The next free embedding row.
+   * @param reserved The reserved symbols, in the order they take their rows.
+   * @param observed The symbols seen in the training data; sorted in place.
+   * @param label The vocabulary name used in error messages.
+   * @return The next free embedding row after the vocabulary.
+   * @throws IllegalArgumentException Thrown if the vocabulary exceeds
+   *         {@link ResourceLimits#MAX_ENTRIES} entries or a symbol as many 
UTF-8 bytes.
+   */
+  private static int assignRows(Map<String, Integer> ids, int row, String[] 
reserved,
+      List<String> observed, String label) {
+    int next = row;
+    for (final String symbol : reserved) {
+      ids.put(symbol, next++);
+    }
+    observed.sort(null);
+    for (final String symbol : observed) {
+      if (!ids.containsKey(symbol)) {
+        FeedforwardNetwork.requireStorable(symbol, label);
+        ids.put(symbol, next++);
+      }
+    }
+    ResourceLimits.requireWithinMaxEntries(ids.size(), label + " size");

Review Comment:
   Trainer now checks word, tag and label counts together against 
ResourceLimits.MAX_ENTRIES, same limit the loader uses. Added tests at the 
limit and one past it.
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1123 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: 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
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The hyperparameters are read from {@link TrainingParameters}: the keys 
of this
+ * class plus {@link Parameters#ITERATIONS_PARAM} for the number of epochs,
+ * {@link Parameters#CUTOFF_PARAM} for the minimum word frequency, and
+ * {@link BeamSearch#BEAM_SIZE_PARAMETER} for the refinement beam. Other keys 
are
+ * ignored.</p>
+ *
+ * <p>Words below the frequency cutoff share the reserved unknown row, which is
+ * therefore trained only when such words exist; the unknown rows of the tag 
and label
+ * vocabularies keep their random initialization. Absent template positions 
share a
+ * trained padding row. Non-projective samples have no arc-standard derivation 
and are
+ * skipped. Training is deterministic for a fixed {@link #SEED_PARAM}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+  /** The training parameter key of the embedding dimensionality; default 
{@value #DEFAULT_EMBEDDING_SIZE}. */
+  public static final String EMBEDDING_SIZE_PARAM = "EmbeddingSize";
+
+  /** The training parameter key of the hidden layer width; default {@value 
#DEFAULT_HIDDEN_SIZE}. */
+  public static final String HIDDEN_SIZE_PARAM = "HiddenSize";
+
+  /** The training parameter key of the minibatch size; default {@value 
#DEFAULT_BATCH_SIZE}. */
+  public static final String BATCH_SIZE_PARAM = "BatchSize";
+
+  /** The training parameter key of the AdaGrad step size; default {@value 
#DEFAULT_LEARNING_RATE}. */
+  public static final String LEARNING_RATE_PARAM = "LearningRate";
+
+  /** The training parameter key of the L2 penalty on dense weights; default 
{@value #DEFAULT_L2}. */
+  public static final String L2_PARAM = "L2";
+
+  /** The training parameter key of the hidden dropout probability; default 
{@value #DEFAULT_DROPOUT}. */
+  public static final String DROPOUT_PARAM = "Dropout";
+
+  /**
+   * The training parameter key of the random seed, read as an {@code int}; 
default
+   * {@value #DEFAULT_SEED}.
+   */
+  public static final String SEED_PARAM = "Seed";
+
+  /** The default embedding dimensionality. */
+  public static final int DEFAULT_EMBEDDING_SIZE = 50;
+
+  /** The default hidden layer width. */
+  public static final int DEFAULT_HIDDEN_SIZE = 200;
+
+  /** The default number of epochs. */
+  public static final int DEFAULT_EPOCHS = 10;
+
+  /** The default minibatch size. */
+  public static final int DEFAULT_BATCH_SIZE = 256;
+
+  /** The default AdaGrad step size. */
+  public static final double DEFAULT_LEARNING_RATE = 0.02;
+
+  /** The default L2 penalty. */
+  public static final double DEFAULT_L2 = 1e-8;
+
+  /** The default hidden dropout probability. */
+  public static final double DEFAULT_DROPOUT = 0.5;
+
+  /** The default minimum word frequency for an embedding row. */
+  public static final int DEFAULT_WORD_CUTOFF = 2;
+
+  /** The default random seed. */
+  public static final int DEFAULT_SEED = 17;
+
+  /** The default refinement beam size. */
+  public static final int DEFAULT_BEAM_SIZE = 4;
+
+  /** The maximum embedding width. */
+  static final int MAX_EMBEDDING_SIZE = 4_096;
+
+  /** The maximum hidden-layer width. */
+  static final int MAX_HIDDEN_SIZE = 65_536;
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+  /** The AdaGrad denominator offset that keeps the first steps finite. */
+  private static final double ADAGRAD_EPSILON = 1e-6;
+
+  /** The derivative factor of the cube activation, {@code d/dx x^3 = 3x^2}. */
+  private static final double CUBE_DERIVATIVE_FACTOR = 3.0;
+
+  /** The numerator of the Glorot uniform bound, {@code sqrt(6 / (fanIn + 
fanOut))}. */
+  private static final double GLOROT_NUMERATOR = 6.0;
+
+  /** The half-width of the uniform range embeddings are drawn from. */
+  private static final double EMBEDDING_INIT_SCALE = 0.01;
+
+  /** The smallest probability the reported training loss takes the log of. */
+  private static final double LOSS_PROBABILITY_FLOOR = 1e-12;
+
+  /** SHIFT and at least one RIGHT_ARC are required to parse a sentence. */
+  private static final int MIN_TRANSITIONS = 2;
+
+  /** The smallest beam that can lose the gold derivation to another path. */
+  private static final int MIN_REFINE_BEAM_SIZE = 2;
+
+  /** Prevents construction of this utility class. */
+  private FeedforwardDependencyTrainer() {
+  }
+
+  /**
+   * The parsed hyperparameters of one training or refinement run.
+   *
+   * @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.
+   * @param beamSize The refinement beam width; {@link #refine} requires at 
least 2.
+   */
+  record Settings(int embeddingSize, int hiddenSize, int epochs, int batchSize,
+      double learningRate, double l2, double dropout, int wordCutoff, long 
seed,
+      int beamSize) {
+
+    /**
+     * Validates the hyperparameters.
+     *
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range or the smallest network of this shape exceeds
+     *         {@link ResourceLimits#MAX_MATRIX_CELLS} float values.
+     */
+    Settings {
+      ParamChecks.requirePositive(embeddingSize, EMBEDDING_SIZE_PARAM);
+      ParamChecks.requirePositive(hiddenSize, HIDDEN_SIZE_PARAM);
+      ParamChecks.requirePositive(epochs, Parameters.ITERATIONS_PARAM);
+      ParamChecks.requirePositive(batchSize, BATCH_SIZE_PARAM);
+      if (embeddingSize > MAX_EMBEDDING_SIZE) {
+        throw new IllegalArgumentException(EMBEDDING_SIZE_PARAM + " exceeds " 
+ MAX_EMBEDDING_SIZE
+            + ": " + embeddingSize);
+      }
+      if (hiddenSize > MAX_HIDDEN_SIZE) {
+        throw new IllegalArgumentException(HIDDEN_SIZE_PARAM + " exceeds " + 
MAX_HIDDEN_SIZE
+            + ": " + hiddenSize);
+      }
+      requireWithinCellLimit(FeedforwardNetwork.RESERVED_ROWS, MIN_TRANSITIONS,
+          embeddingSize, hiddenSize);
+      ParamChecks.requirePositive(learningRate, LEARNING_RATE_PARAM);
+      ParamChecks.requireNonNegative(ParamChecks.requireFinite(l2, L2_PARAM), 
L2_PARAM);
+      if (!(dropout >= 0.0 && dropout < 1.0)) {
+        throw new IllegalArgumentException(DROPOUT_PARAM + " must be in [0, 
1): " + dropout);
+      }
+      ParamChecks.requireNonNegative(wordCutoff, Parameters.CUTOFF_PARAM);
+    }
+
+    /**
+     * Reads the hyperparameters, substituting the defaults for absent keys.
+     *
+     * @param parameters The training parameters. Must not be {@code null}.
+     * @return The parsed settings. Never {@code null}.
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range.
+     */
+    static Settings from(TrainingParameters parameters) {
+      return new Settings(
+          parameters.getIntParameter(EMBEDDING_SIZE_PARAM, 
DEFAULT_EMBEDDING_SIZE),
+          parameters.getIntParameter(HIDDEN_SIZE_PARAM, DEFAULT_HIDDEN_SIZE),
+          parameters.getIntParameter(Parameters.ITERATIONS_PARAM, 
DEFAULT_EPOCHS),
+          parameters.getIntParameter(BATCH_SIZE_PARAM, DEFAULT_BATCH_SIZE),
+          parameters.getDoubleParameter(LEARNING_RATE_PARAM, 
DEFAULT_LEARNING_RATE),
+          parameters.getDoubleParameter(L2_PARAM, DEFAULT_L2),
+          parameters.getDoubleParameter(DROPOUT_PARAM, DEFAULT_DROPOUT),
+          parameters.getIntParameter(Parameters.CUTOFF_PARAM, 
DEFAULT_WORD_CUTOFF),
+          parameters.getIntParameter(SEED_PARAM, DEFAULT_SEED),
+          parameters.getIntParameter(BeamSearch.BEAM_SIZE_PARAMETER, 
DEFAULT_BEAM_SIZE));
+    }
+  }
+
+  /**
+   * Trains a model from dependency samples.
+   *
+   * @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}.
+   * @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}, a
+   *         hyperparameter is out of range, no trainable example can be 
derived from
+   *         the samples, a vocabulary exceeds {@link 
ResourceLimits#MAX_ENTRIES}
+   *         entries or a symbol as many UTF-8 bytes, or the network would 
store more
+   *         than {@link ResourceLimits#MAX_MATRIX_CELLS} values.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters)
+      throws IOException {
+    return train(languageCode, samples, parameters, null);
+  }
+
+  /**
+   * Trains a model from dependency samples, seeding word embeddings from a 
pretrained
+   * source.
+   *
+   * <p>The provider is consulted once per vocabulary word during 
initialization. Words
+   * it returns {@code null} for keep their random initialization. All 
embeddings remain
+   * trainable and are stored in the model.</p>
+   *
+   * @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}.
+   * @param pretrained Maps a normalized word to its pretrained vector of 
exactly
+   *                   {@link #EMBEDDING_SIZE_PARAM} 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 languageCode}, {@code 
samples},
+   *         or {@code parameters} is {@code null}, a hyperparameter is out of 
range, no
+   *         trainable example can be derived, a vocabulary exceeds
+   *         {@link ResourceLimits#MAX_ENTRIES} entries or a symbol as many 
UTF-8 bytes,
+   *         the network would store more than {@link 
ResourceLimits#MAX_MATRIX_CELLS}
+   *         values, or a pretrained vector has the wrong dimensionality or a 
non-finite
+   *         value.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters,
+      Function<String, float[]> pretrained) throws IOException {
+    ParamChecks.requireNonNullArg(languageCode, "languageCode");
+    ParamChecks.requireNonNullArg(samples, "samples");
+    ParamChecks.requireNonNullArg(parameters, "parameters");
+    final Settings settings = Settings.from(parameters);
+    final List<Derivation> derivations = derive(samples);
+    if (derivations.isEmpty()) {
+      throw new IllegalArgumentException("no trainable examples in the 
samples");
+    }
+    final FeedforwardNetwork network = initialize(derivations, settings);
+    if (pretrained != null) {
+      seed(network, pretrained, settings);
+    }
+    int exampleCount = 0;
+    for (final Derivation derivation : derivations) {
+      exampleCount += derivation.oracle().size();
+    }
+    final int[][] features = new int[exampleCount][];
+    final int[] gold = new int[exampleCount];
+    collectExamples(derivations, network, features, gold);
+    optimize(network, features, gold, settings);
+    return new FeedforwardDependencyModel(languageCode, network, 
manifestEntries(settings));
+  }
+
+  /**
+   * Refines a locally trained model with beam search and the early-update 
method of
+   * <a href="https://aclanthology.org/P04-1015/";>Collins and Roark 
(2004)</a>. The loss
+   * is conditional likelihood over candidate paths scored by summed 
log-probabilities.
+   *
+   * <p>Refinement trains a copy with per-sentence AdaGrad steps and no 
dropout.
+   * {@link Parameters#ITERATIONS_PARAM} sets the number of passes and
+   * {@link BeamSearch#BEAM_SIZE_PARAMETER} the beam width, which is also the 
beam size
+   * to parse the result with. The transition inventory of {@code model} is not
+   * extended.</p>
+   *
+   * @param model The locally trained model. Not modified. Must not be {@code 
null}.
+   * @param samples The training samples. Must not be {@code null}.
+   * @param parameters The {@link TrainingParameters}; the epochs, learning 
rate, L2
+   *                   penalty, seed, and beam size apply. Must not be {@code 
null}.
+   * @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}, a
+   *         hyperparameter is out of range, the beam size is below 2, no 
trainable
+   *         sample can be derived, or a sample requires a transition {@code 
model}
+   *         does not know.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.

Review Comment:
   Added to the Javadoc. refine throws IllegalStateException if a score is 
non-finite while decoding.
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1123 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: 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
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The hyperparameters are read from {@link TrainingParameters}: the keys 
of this
+ * class plus {@link Parameters#ITERATIONS_PARAM} for the number of epochs,
+ * {@link Parameters#CUTOFF_PARAM} for the minimum word frequency, and
+ * {@link BeamSearch#BEAM_SIZE_PARAMETER} for the refinement beam. Other keys 
are
+ * ignored.</p>
+ *
+ * <p>Words below the frequency cutoff share the reserved unknown row, which is
+ * therefore trained only when such words exist; the unknown rows of the tag 
and label
+ * vocabularies keep their random initialization. Absent template positions 
share a
+ * trained padding row. Non-projective samples have no arc-standard derivation 
and are
+ * skipped. Training is deterministic for a fixed {@link #SEED_PARAM}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+  /** The training parameter key of the embedding dimensionality; default 
{@value #DEFAULT_EMBEDDING_SIZE}. */
+  public static final String EMBEDDING_SIZE_PARAM = "EmbeddingSize";
+
+  /** The training parameter key of the hidden layer width; default {@value 
#DEFAULT_HIDDEN_SIZE}. */
+  public static final String HIDDEN_SIZE_PARAM = "HiddenSize";
+
+  /** The training parameter key of the minibatch size; default {@value 
#DEFAULT_BATCH_SIZE}. */
+  public static final String BATCH_SIZE_PARAM = "BatchSize";
+
+  /** The training parameter key of the AdaGrad step size; default {@value 
#DEFAULT_LEARNING_RATE}. */
+  public static final String LEARNING_RATE_PARAM = "LearningRate";
+
+  /** The training parameter key of the L2 penalty on dense weights; default 
{@value #DEFAULT_L2}. */
+  public static final String L2_PARAM = "L2";
+
+  /** The training parameter key of the hidden dropout probability; default 
{@value #DEFAULT_DROPOUT}. */
+  public static final String DROPOUT_PARAM = "Dropout";
+
+  /**
+   * The training parameter key of the random seed, read as an {@code int}; 
default
+   * {@value #DEFAULT_SEED}.
+   */
+  public static final String SEED_PARAM = "Seed";
+
+  /** The default embedding dimensionality. */
+  public static final int DEFAULT_EMBEDDING_SIZE = 50;
+
+  /** The default hidden layer width. */
+  public static final int DEFAULT_HIDDEN_SIZE = 200;
+
+  /** The default number of epochs. */
+  public static final int DEFAULT_EPOCHS = 10;
+
+  /** The default minibatch size. */
+  public static final int DEFAULT_BATCH_SIZE = 256;
+
+  /** The default AdaGrad step size. */
+  public static final double DEFAULT_LEARNING_RATE = 0.02;
+
+  /** The default L2 penalty. */
+  public static final double DEFAULT_L2 = 1e-8;
+
+  /** The default hidden dropout probability. */
+  public static final double DEFAULT_DROPOUT = 0.5;
+
+  /** The default minimum word frequency for an embedding row. */
+  public static final int DEFAULT_WORD_CUTOFF = 2;
+
+  /** The default random seed. */
+  public static final int DEFAULT_SEED = 17;
+
+  /** The default refinement beam size. */
+  public static final int DEFAULT_BEAM_SIZE = 4;
+
+  /** The maximum embedding width. */
+  static final int MAX_EMBEDDING_SIZE = 4_096;
+
+  /** The maximum hidden-layer width. */
+  static final int MAX_HIDDEN_SIZE = 65_536;
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+  /** The AdaGrad denominator offset that keeps the first steps finite. */
+  private static final double ADAGRAD_EPSILON = 1e-6;
+
+  /** The derivative factor of the cube activation, {@code d/dx x^3 = 3x^2}. */
+  private static final double CUBE_DERIVATIVE_FACTOR = 3.0;
+
+  /** The numerator of the Glorot uniform bound, {@code sqrt(6 / (fanIn + 
fanOut))}. */
+  private static final double GLOROT_NUMERATOR = 6.0;
+
+  /** The half-width of the uniform range embeddings are drawn from. */
+  private static final double EMBEDDING_INIT_SCALE = 0.01;
+
+  /** The smallest probability the reported training loss takes the log of. */
+  private static final double LOSS_PROBABILITY_FLOOR = 1e-12;
+
+  /** SHIFT and at least one RIGHT_ARC are required to parse a sentence. */
+  private static final int MIN_TRANSITIONS = 2;
+
+  /** The smallest beam that can lose the gold derivation to another path. */
+  private static final int MIN_REFINE_BEAM_SIZE = 2;
+
+  /** Prevents construction of this utility class. */
+  private FeedforwardDependencyTrainer() {
+  }
+
+  /**
+   * The parsed hyperparameters of one training or refinement run.
+   *
+   * @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.
+   * @param beamSize The refinement beam width; {@link #refine} requires at 
least 2.
+   */
+  record Settings(int embeddingSize, int hiddenSize, int epochs, int batchSize,
+      double learningRate, double l2, double dropout, int wordCutoff, long 
seed,
+      int beamSize) {
+
+    /**
+     * Validates the hyperparameters.
+     *
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range or the smallest network of this shape exceeds
+     *         {@link ResourceLimits#MAX_MATRIX_CELLS} float values.
+     */
+    Settings {
+      ParamChecks.requirePositive(embeddingSize, EMBEDDING_SIZE_PARAM);
+      ParamChecks.requirePositive(hiddenSize, HIDDEN_SIZE_PARAM);
+      ParamChecks.requirePositive(epochs, Parameters.ITERATIONS_PARAM);
+      ParamChecks.requirePositive(batchSize, BATCH_SIZE_PARAM);
+      if (embeddingSize > MAX_EMBEDDING_SIZE) {
+        throw new IllegalArgumentException(EMBEDDING_SIZE_PARAM + " exceeds " 
+ MAX_EMBEDDING_SIZE
+            + ": " + embeddingSize);
+      }
+      if (hiddenSize > MAX_HIDDEN_SIZE) {
+        throw new IllegalArgumentException(HIDDEN_SIZE_PARAM + " exceeds " + 
MAX_HIDDEN_SIZE
+            + ": " + hiddenSize);
+      }
+      requireWithinCellLimit(FeedforwardNetwork.RESERVED_ROWS, MIN_TRANSITIONS,
+          embeddingSize, hiddenSize);
+      ParamChecks.requirePositive(learningRate, LEARNING_RATE_PARAM);
+      ParamChecks.requireNonNegative(ParamChecks.requireFinite(l2, L2_PARAM), 
L2_PARAM);
+      if (!(dropout >= 0.0 && dropout < 1.0)) {
+        throw new IllegalArgumentException(DROPOUT_PARAM + " must be in [0, 
1): " + dropout);
+      }
+      ParamChecks.requireNonNegative(wordCutoff, Parameters.CUTOFF_PARAM);
+    }
+
+    /**
+     * Reads the hyperparameters, substituting the defaults for absent keys.
+     *
+     * @param parameters The training parameters. Must not be {@code null}.
+     * @return The parsed settings. Never {@code null}.
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range.
+     */
+    static Settings from(TrainingParameters parameters) {
+      return new Settings(
+          parameters.getIntParameter(EMBEDDING_SIZE_PARAM, 
DEFAULT_EMBEDDING_SIZE),
+          parameters.getIntParameter(HIDDEN_SIZE_PARAM, DEFAULT_HIDDEN_SIZE),
+          parameters.getIntParameter(Parameters.ITERATIONS_PARAM, 
DEFAULT_EPOCHS),
+          parameters.getIntParameter(BATCH_SIZE_PARAM, DEFAULT_BATCH_SIZE),
+          parameters.getDoubleParameter(LEARNING_RATE_PARAM, 
DEFAULT_LEARNING_RATE),
+          parameters.getDoubleParameter(L2_PARAM, DEFAULT_L2),
+          parameters.getDoubleParameter(DROPOUT_PARAM, DEFAULT_DROPOUT),
+          parameters.getIntParameter(Parameters.CUTOFF_PARAM, 
DEFAULT_WORD_CUTOFF),
+          parameters.getIntParameter(SEED_PARAM, DEFAULT_SEED),
+          parameters.getIntParameter(BeamSearch.BEAM_SIZE_PARAMETER, 
DEFAULT_BEAM_SIZE));
+    }
+  }
+
+  /**
+   * Trains a model from dependency samples.
+   *
+   * @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}.
+   * @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}, a
+   *         hyperparameter is out of range, no trainable example can be 
derived from
+   *         the samples, a vocabulary exceeds {@link 
ResourceLimits#MAX_ENTRIES}
+   *         entries or a symbol as many UTF-8 bytes, or the network would 
store more
+   *         than {@link ResourceLimits#MAX_MATRIX_CELLS} values.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters)
+      throws IOException {
+    return train(languageCode, samples, parameters, null);
+  }
+
+  /**
+   * Trains a model from dependency samples, seeding word embeddings from a 
pretrained
+   * source.
+   *
+   * <p>The provider is consulted once per vocabulary word during 
initialization. Words
+   * it returns {@code null} for keep their random initialization. All 
embeddings remain
+   * trainable and are stored in the model.</p>
+   *
+   * @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}.
+   * @param pretrained Maps a normalized word to its pretrained vector of 
exactly
+   *                   {@link #EMBEDDING_SIZE_PARAM} 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 languageCode}, {@code 
samples},
+   *         or {@code parameters} is {@code null}, a hyperparameter is out of 
range, no
+   *         trainable example can be derived, a vocabulary exceeds
+   *         {@link ResourceLimits#MAX_ENTRIES} entries or a symbol as many 
UTF-8 bytes,
+   *         the network would store more than {@link 
ResourceLimits#MAX_MATRIX_CELLS}
+   *         values, or a pretrained vector has the wrong dimensionality or a 
non-finite
+   *         value.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters,
+      Function<String, float[]> pretrained) throws IOException {
+    ParamChecks.requireNonNullArg(languageCode, "languageCode");
+    ParamChecks.requireNonNullArg(samples, "samples");
+    ParamChecks.requireNonNullArg(parameters, "parameters");
+    final Settings settings = Settings.from(parameters);
+    final List<Derivation> derivations = derive(samples);
+    if (derivations.isEmpty()) {
+      throw new IllegalArgumentException("no trainable examples in the 
samples");
+    }
+    final FeedforwardNetwork network = initialize(derivations, settings);
+    if (pretrained != null) {
+      seed(network, pretrained, settings);
+    }
+    int exampleCount = 0;
+    for (final Derivation derivation : derivations) {
+      exampleCount += derivation.oracle().size();
+    }
+    final int[][] features = new int[exampleCount][];
+    final int[] gold = new int[exampleCount];
+    collectExamples(derivations, network, features, gold);
+    optimize(network, features, gold, settings);
+    return new FeedforwardDependencyModel(languageCode, network, 
manifestEntries(settings));
+  }
+
+  /**
+   * Refines a locally trained model with beam search and the early-update 
method of
+   * <a href="https://aclanthology.org/P04-1015/";>Collins and Roark 
(2004)</a>. The loss
+   * is conditional likelihood over candidate paths scored by summed 
log-probabilities.
+   *
+   * <p>Refinement trains a copy with per-sentence AdaGrad steps and no 
dropout.
+   * {@link Parameters#ITERATIONS_PARAM} sets the number of passes and
+   * {@link BeamSearch#BEAM_SIZE_PARAMETER} the beam width, which is also the 
beam size

Review Comment:
   Kept BeamSize since #1473 uses it as the default beam for a refined model. 
Cutoff now comes from the input model since refinement does not change the word 
list. Test checks both.
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1123 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: 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
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The hyperparameters are read from {@link TrainingParameters}: the keys 
of this
+ * class plus {@link Parameters#ITERATIONS_PARAM} for the number of epochs,
+ * {@link Parameters#CUTOFF_PARAM} for the minimum word frequency, and
+ * {@link BeamSearch#BEAM_SIZE_PARAMETER} for the refinement beam. Other keys 
are
+ * ignored.</p>
+ *
+ * <p>Words below the frequency cutoff share the reserved unknown row, which is
+ * therefore trained only when such words exist; the unknown rows of the tag 
and label
+ * vocabularies keep their random initialization. Absent template positions 
share a
+ * trained padding row. Non-projective samples have no arc-standard derivation 
and are
+ * skipped. Training is deterministic for a fixed {@link #SEED_PARAM}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+  /** The training parameter key of the embedding dimensionality; default 
{@value #DEFAULT_EMBEDDING_SIZE}. */
+  public static final String EMBEDDING_SIZE_PARAM = "EmbeddingSize";
+
+  /** The training parameter key of the hidden layer width; default {@value 
#DEFAULT_HIDDEN_SIZE}. */
+  public static final String HIDDEN_SIZE_PARAM = "HiddenSize";
+
+  /** The training parameter key of the minibatch size; default {@value 
#DEFAULT_BATCH_SIZE}. */
+  public static final String BATCH_SIZE_PARAM = "BatchSize";
+
+  /** The training parameter key of the AdaGrad step size; default {@value 
#DEFAULT_LEARNING_RATE}. */
+  public static final String LEARNING_RATE_PARAM = "LearningRate";
+
+  /** The training parameter key of the L2 penalty on dense weights; default 
{@value #DEFAULT_L2}. */
+  public static final String L2_PARAM = "L2";

Review Comment:
   Removed L2_PARAM; the trainer reads QNTrainer.L2COST_PARAM ("L2Cost").
   



##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1123 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: 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
+ * <a href="https://aclanthology.org/D14-1082/";>Chen and Manning (2014)</a>.
+ *
+ * <p>The hyperparameters are read from {@link TrainingParameters}: the keys 
of this
+ * class plus {@link Parameters#ITERATIONS_PARAM} for the number of epochs,
+ * {@link Parameters#CUTOFF_PARAM} for the minimum word frequency, and
+ * {@link BeamSearch#BEAM_SIZE_PARAMETER} for the refinement beam. Other keys 
are
+ * ignored.</p>
+ *
+ * <p>Words below the frequency cutoff share the reserved unknown row, which is
+ * therefore trained only when such words exist; the unknown rows of the tag 
and label
+ * vocabularies keep their random initialization. Absent template positions 
share a
+ * trained padding row. Non-projective samples have no arc-standard derivation 
and are
+ * skipped. Training is deterministic for a fixed {@link #SEED_PARAM}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+  /** The training parameter key of the embedding dimensionality; default 
{@value #DEFAULT_EMBEDDING_SIZE}. */
+  public static final String EMBEDDING_SIZE_PARAM = "EmbeddingSize";
+
+  /** The training parameter key of the hidden layer width; default {@value 
#DEFAULT_HIDDEN_SIZE}. */
+  public static final String HIDDEN_SIZE_PARAM = "HiddenSize";
+
+  /** The training parameter key of the minibatch size; default {@value 
#DEFAULT_BATCH_SIZE}. */
+  public static final String BATCH_SIZE_PARAM = "BatchSize";
+
+  /** The training parameter key of the AdaGrad step size; default {@value 
#DEFAULT_LEARNING_RATE}. */
+  public static final String LEARNING_RATE_PARAM = "LearningRate";
+
+  /** The training parameter key of the L2 penalty on dense weights; default 
{@value #DEFAULT_L2}. */
+  public static final String L2_PARAM = "L2";
+
+  /** The training parameter key of the hidden dropout probability; default 
{@value #DEFAULT_DROPOUT}. */
+  public static final String DROPOUT_PARAM = "Dropout";
+
+  /**
+   * The training parameter key of the random seed, read as an {@code int}; 
default
+   * {@value #DEFAULT_SEED}.
+   */
+  public static final String SEED_PARAM = "Seed";
+
+  /** The default embedding dimensionality. */
+  public static final int DEFAULT_EMBEDDING_SIZE = 50;
+
+  /** The default hidden layer width. */
+  public static final int DEFAULT_HIDDEN_SIZE = 200;
+
+  /** The default number of epochs. */
+  public static final int DEFAULT_EPOCHS = 10;
+
+  /** The default minibatch size. */
+  public static final int DEFAULT_BATCH_SIZE = 256;
+
+  /** The default AdaGrad step size. */
+  public static final double DEFAULT_LEARNING_RATE = 0.02;
+
+  /** The default L2 penalty. */
+  public static final double DEFAULT_L2 = 1e-8;
+
+  /** The default hidden dropout probability. */
+  public static final double DEFAULT_DROPOUT = 0.5;
+
+  /** The default minimum word frequency for an embedding row. */
+  public static final int DEFAULT_WORD_CUTOFF = 2;
+
+  /** The default random seed. */
+  public static final int DEFAULT_SEED = 17;
+
+  /** The default refinement beam size. */
+  public static final int DEFAULT_BEAM_SIZE = 4;
+
+  /** The maximum embedding width. */
+  static final int MAX_EMBEDDING_SIZE = 4_096;
+
+  /** The maximum hidden-layer width. */
+  static final int MAX_HIDDEN_SIZE = 65_536;
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+  /** The AdaGrad denominator offset that keeps the first steps finite. */
+  private static final double ADAGRAD_EPSILON = 1e-6;
+
+  /** The derivative factor of the cube activation, {@code d/dx x^3 = 3x^2}. */
+  private static final double CUBE_DERIVATIVE_FACTOR = 3.0;
+
+  /** The numerator of the Glorot uniform bound, {@code sqrt(6 / (fanIn + 
fanOut))}. */
+  private static final double GLOROT_NUMERATOR = 6.0;
+
+  /** The half-width of the uniform range embeddings are drawn from. */
+  private static final double EMBEDDING_INIT_SCALE = 0.01;
+
+  /** The smallest probability the reported training loss takes the log of. */
+  private static final double LOSS_PROBABILITY_FLOOR = 1e-12;
+
+  /** SHIFT and at least one RIGHT_ARC are required to parse a sentence. */
+  private static final int MIN_TRANSITIONS = 2;
+
+  /** The smallest beam that can lose the gold derivation to another path. */
+  private static final int MIN_REFINE_BEAM_SIZE = 2;
+
+  /** Prevents construction of this utility class. */
+  private FeedforwardDependencyTrainer() {
+  }
+
+  /**
+   * The parsed hyperparameters of one training or refinement run.
+   *
+   * @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.
+   * @param beamSize The refinement beam width; {@link #refine} requires at 
least 2.
+   */
+  record Settings(int embeddingSize, int hiddenSize, int epochs, int batchSize,
+      double learningRate, double l2, double dropout, int wordCutoff, long 
seed,
+      int beamSize) {
+
+    /**
+     * Validates the hyperparameters.
+     *
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range or the smallest network of this shape exceeds
+     *         {@link ResourceLimits#MAX_MATRIX_CELLS} float values.
+     */
+    Settings {
+      ParamChecks.requirePositive(embeddingSize, EMBEDDING_SIZE_PARAM);
+      ParamChecks.requirePositive(hiddenSize, HIDDEN_SIZE_PARAM);
+      ParamChecks.requirePositive(epochs, Parameters.ITERATIONS_PARAM);
+      ParamChecks.requirePositive(batchSize, BATCH_SIZE_PARAM);
+      if (embeddingSize > MAX_EMBEDDING_SIZE) {
+        throw new IllegalArgumentException(EMBEDDING_SIZE_PARAM + " exceeds " 
+ MAX_EMBEDDING_SIZE
+            + ": " + embeddingSize);
+      }
+      if (hiddenSize > MAX_HIDDEN_SIZE) {
+        throw new IllegalArgumentException(HIDDEN_SIZE_PARAM + " exceeds " + 
MAX_HIDDEN_SIZE
+            + ": " + hiddenSize);
+      }
+      requireWithinCellLimit(FeedforwardNetwork.RESERVED_ROWS, MIN_TRANSITIONS,
+          embeddingSize, hiddenSize);
+      ParamChecks.requirePositive(learningRate, LEARNING_RATE_PARAM);
+      ParamChecks.requireNonNegative(ParamChecks.requireFinite(l2, L2_PARAM), 
L2_PARAM);
+      if (!(dropout >= 0.0 && dropout < 1.0)) {
+        throw new IllegalArgumentException(DROPOUT_PARAM + " must be in [0, 
1): " + dropout);
+      }
+      ParamChecks.requireNonNegative(wordCutoff, Parameters.CUTOFF_PARAM);
+    }
+
+    /**
+     * Reads the hyperparameters, substituting the defaults for absent keys.
+     *
+     * @param parameters The training parameters. Must not be {@code null}.
+     * @return The parsed settings. Never {@code null}.
+     * @throws IllegalArgumentException Thrown if a value is outside its 
documented
+     *         range.
+     */
+    static Settings from(TrainingParameters parameters) {
+      return new Settings(
+          parameters.getIntParameter(EMBEDDING_SIZE_PARAM, 
DEFAULT_EMBEDDING_SIZE),
+          parameters.getIntParameter(HIDDEN_SIZE_PARAM, DEFAULT_HIDDEN_SIZE),
+          parameters.getIntParameter(Parameters.ITERATIONS_PARAM, 
DEFAULT_EPOCHS),
+          parameters.getIntParameter(BATCH_SIZE_PARAM, DEFAULT_BATCH_SIZE),
+          parameters.getDoubleParameter(LEARNING_RATE_PARAM, 
DEFAULT_LEARNING_RATE),
+          parameters.getDoubleParameter(L2_PARAM, DEFAULT_L2),
+          parameters.getDoubleParameter(DROPOUT_PARAM, DEFAULT_DROPOUT),
+          parameters.getIntParameter(Parameters.CUTOFF_PARAM, 
DEFAULT_WORD_CUTOFF),
+          parameters.getIntParameter(SEED_PARAM, DEFAULT_SEED),
+          parameters.getIntParameter(BeamSearch.BEAM_SIZE_PARAMETER, 
DEFAULT_BEAM_SIZE));
+    }
+  }
+
+  /**
+   * Trains a model from dependency samples.
+   *
+   * @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}.
+   * @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}, a
+   *         hyperparameter is out of range, no trainable example can be 
derived from
+   *         the samples, a vocabulary exceeds {@link 
ResourceLimits#MAX_ENTRIES}
+   *         entries or a symbol as many UTF-8 bytes, or the network would 
store more
+   *         than {@link ResourceLimits#MAX_MATRIX_CELLS} values.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters)
+      throws IOException {
+    return train(languageCode, samples, parameters, null);
+  }
+
+  /**
+   * Trains a model from dependency samples, seeding word embeddings from a 
pretrained
+   * source.
+   *
+   * <p>The provider is consulted once per vocabulary word during 
initialization. Words
+   * it returns {@code null} for keep their random initialization. All 
embeddings remain
+   * trainable and are stored in the model.</p>
+   *
+   * @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}.
+   * @param pretrained Maps a normalized word to its pretrained vector of 
exactly
+   *                   {@link #EMBEDDING_SIZE_PARAM} 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 languageCode}, {@code 
samples},
+   *         or {@code parameters} is {@code null}, a hyperparameter is out of 
range, no
+   *         trainable example can be derived, a vocabulary exceeds
+   *         {@link ResourceLimits#MAX_ENTRIES} entries or a symbol as many 
UTF-8 bytes,
+   *         the network would store more than {@link 
ResourceLimits#MAX_MATRIX_CELLS}
+   *         values, or a pretrained vector has the wrong dimensionality or a 
non-finite
+   *         value.
+   * @throws IllegalStateException Thrown if an epoch produces a non-finite 
parameter.
+   */
+  public static FeedforwardDependencyModel train(String languageCode,

Review Comment:
   Dropped it along with the seeding code and its test.
   





> Add a feedforward neural dependency parser
> ------------------------------------------
>
>                 Key: OPENNLP-1991
>                 URL: https://issues.apache.org/jira/browse/OPENNLP-1991
>             Project: OpenNLP
>          Issue Type: Improvement
>            Reporter: Kristian Rickert
>            Assignee: Kristian Rickert
>            Priority: Major
>          Time Spent: 10m
>  Remaining Estimate: 0h
>
> Add a feedforward neural implementation of the dependency parser API 
> introduced by OPENNLP-547. Use OpenNLP’s standard model container, artifact 
> serializers, TrainingParameters, factory, and CLI conventions. Include 
> deterministic training, greedy and beam decoding, model round-trip tests, and 
> held-out Universal Dependencies evaluation. Address the deferred review 
> feedback on shared scoring/training logic and parser-owned caches.  Implement 
> this without new dependencies.
> The list above may still get this ticket to further split.  For now this is a 
> placeholder to capture the code that was moved out of OPENNLP-547.  I'll edit 
> after 547 is merged.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to