This is an automated email from the ASF dual-hosted git repository.

mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/main by this push:
     new 5807d75b0 GH-1469: Add command-line tools for the feedforward 
dependency parser (#1473)
5807d75b0 is described below

commit 5807d75b0ba43a4ad7c685678f6b2287a2d53c4b
Author: Kristian Rickert <[email protected]>
AuthorDate: Thu Oct 22 04:08:39 2026 -0400

    GH-1469: Add command-line tools for the feedforward dependency parser 
(#1473)
    
    * GH-1469: Add command-line tools for the feedforward dependency parser
    
    FeedforwardDependencyParserTrainer trains a FeedforwardDependencyModel from
    the depparse sample formats. The -params file takes the trainer keys, and
    without a file the trainer defaults apply. -refineParams refines the model
    with beam search; with -baseModel the tool refines a saved model instead
    of training one. FeedforwardDependencyParser parses tagged lines from
    standard input and FeedforwardDependencyParserEvaluator logs UAS and LAS
    in the format of the maxent tools. Both take -beamSize, defaulting to the
    beam size a refined model records in the manifest, or 1. The manual's
    dependency chapter documents the tools.
    
    ModelLoader now reports a model of another component as "Model has
    invalid format" for all loaders instead of a raw IllegalArgumentException.
    The CoNLL-U output loop and the scoring step are shared with the maxent
    tools, and the depparse CLI tests use one fixture class.
    
    ---------
    
    Signed-off-by: Kristian Rickert <[email protected]>
    Co-authored-by: Claude Code 2.1.296 (Opus 5.5) <[email protected]>
---
 .../src/main/java/opennlp/tools/cmdline/CLI.java   |   6 +
 .../java/opennlp/tools/cmdline/ModelLoader.java    |   9 +
 .../DependencyParserCrossValidatorTool.java        |   4 +-
 .../depparse/DependencyParserEvaluatorTool.java    |  20 +-
 .../cmdline/depparse/DependencyParserMETool.java   |  42 +-
 .../depparse/DependencyParserToolSupport.java      | 149 ++++++
 .../depparse/DependencyParserTrainerTool.java      |  15 +-
 .../depparse/FeedforwardDependencyModelLoader.java |  46 ++
 .../FeedforwardDependencyParserEvaluatorTool.java  |  77 +++
 .../depparse/FeedforwardDependencyParserTool.java  |  84 +++
 .../FeedforwardDependencyParserTrainerTool.java    | 125 +++++
 .../depparse/DependencyParserToolsTest.java        | 108 +---
 .../depparse/DependencyToolTestSupport.java        | 134 +++++
 .../FeedforwardDependencyParserToolsTest.java      | 581 +++++++++++++++++++++
 .../tools/depparse/FeedforwardDependencyModel.java |  41 +-
 .../depparse/FeedforwardDependencyParser.java      |  20 +-
 .../depparse/FeedforwardDependencyParserTest.java  |  99 +++-
 opennlp-docs/src/docbkx/dependency.xml             |  35 +-
 18 files changed, 1414 insertions(+), 181 deletions(-)

diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
index ff682d053..dbb259b1c 100644
--- a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
@@ -37,6 +37,9 @@ import 
opennlp.tools.cmdline.depparse.DependencyParserCrossValidatorTool;
 import opennlp.tools.cmdline.depparse.DependencyParserEvaluatorTool;
 import opennlp.tools.cmdline.depparse.DependencyParserMETool;
 import opennlp.tools.cmdline.depparse.DependencyParserTrainerTool;
+import opennlp.tools.cmdline.depparse.FeedforwardDependencyParserEvaluatorTool;
+import opennlp.tools.cmdline.depparse.FeedforwardDependencyParserTool;
+import opennlp.tools.cmdline.depparse.FeedforwardDependencyParserTrainerTool;
 import opennlp.tools.cmdline.dictionary.DictionaryBuilderTool;
 import opennlp.tools.cmdline.doccat.DoccatConverterTool;
 import opennlp.tools.cmdline.doccat.DoccatCrossValidatorTool;
@@ -171,6 +174,9 @@ public final class CLI {
     tools.add(new DependencyParserEvaluatorTool());
     tools.add(new DependencyParserCrossValidatorTool());
     tools.add(new DependencyParserMETool());
+    tools.add(new FeedforwardDependencyParserTrainerTool());
+    tools.add(new FeedforwardDependencyParserEvaluatorTool());
+    tools.add(new FeedforwardDependencyParserTool());
 
     // Chunker
     tools.add(new ChunkerMETool());
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/ModelLoader.java 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/ModelLoader.java
index 180aade29..cd7225e0b 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/ModelLoader.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/ModelLoader.java
@@ -65,6 +65,15 @@ public abstract class ModelLoader<T> {
     catch (InvalidFormatException e) {
       throw new TerminateToolException(-1, "Model has invalid format", e);
     }
+    catch (IllegalArgumentException e) {
+      // BaseModel reports a model of another component, whose manifest names 
a tool
+      // factory this model cannot use, as an IllegalArgumentException with the
+      // InvalidFormatException as its cause.
+      if (e.getCause() instanceof InvalidFormatException) {
+        throw new TerminateToolException(-1, "Model has invalid format", e);
+      }
+      throw e;
+    }
     catch (IOException e) {
       throw new TerminateToolException(-1, "IO error while loading model file 
'" + modelFile + "'", e);
     }
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java
index 0cf1fb6dc..f8bbdd379 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java
@@ -31,6 +31,7 @@ import opennlp.tools.depparse.DependencyCrossValidator;
 import opennlp.tools.depparse.DependencyEvaluationMonitor;
 import opennlp.tools.depparse.DependencySample;
 import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.TrainingParameters;
 
 /** Cross validates an arc-standard dependency parser on CoNLL-U samples. */
 public class DependencyParserCrossValidatorTool
@@ -57,7 +58,8 @@ public class DependencyParserCrossValidatorTool
   public void run(String format, String[] args) {
     super.run(format, args);
     try (ObjectStream<DependencySample> samples = sampleStream) {
-      mlParams = 
DependencyParserTrainerTool.loadTrainingParameters(params.getParams());
+      mlParams = 
DependencyParserToolSupport.loadTrainingParameters(params.getParams(),
+          TrainingParameters.defaultParams());
       DependencyEvaluationMonitor errorListener = null;
       if (params.getMisclassified()) {
         errorListener = new DependencyEvaluationErrorListener();
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.java
index 8d25e52a2..9f105f1ee 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.java
@@ -24,11 +24,8 @@ import org.slf4j.LoggerFactory;
 
 import opennlp.tools.cmdline.AbstractEvaluatorTool;
 import opennlp.tools.cmdline.params.EvaluatorParams;
-import opennlp.tools.depparse.DependencyEvaluationMonitor;
-import opennlp.tools.depparse.DependencyEvaluator;
 import opennlp.tools.depparse.DependencyParserME;
 import opennlp.tools.depparse.DependencySample;
-import opennlp.tools.util.ObjectStream;
 
 /** Reports labeled and unlabeled attachment scores against gold CoNLL-U 
trees. */
 public class DependencyParserEvaluatorTool
@@ -51,19 +48,10 @@ public class DependencyParserEvaluatorTool
   @Override
   public void run(String format, String[] args) {
     super.run(format, args);
-    try (ObjectStream<DependencySample> samples = sampleStream) {
-      DependencyEvaluationMonitor errorListener = null;
-      if (params.getMisclassified()) {
-        errorListener = new DependencyEvaluationErrorListener();
-      }
-      DependencyEvaluator evaluator = new DependencyEvaluator(new 
DependencyParserME(
-          new DependencyModelLoader().load(params.getModel())), errorListener);
-      evaluator.evaluate(samples);
-      logger.info("Tokens: {}; UAS: {}; LAS: {}", evaluator.getWordCount(),
-          evaluator.getUas(), evaluator.getLas());
-      logger.info("Tokens excluding punctuation: {}; UAS: {}; LAS: {}",
-          evaluator.getWordCountExcludingPunctuation(),
-          evaluator.getUasExcludingPunctuation(), 
evaluator.getLasExcludingPunctuation());
+    try {
+      DependencyParserToolSupport.evaluate(
+          () -> new DependencyParserME(new 
DependencyModelLoader().load(params.getModel())),
+          sampleStream, params.getMisclassified(), logger);
     } catch (IOException e) {
       throw createTerminationIOException(e);
     }
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java
index 66cc5342a..f088b2101 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java
@@ -18,30 +18,15 @@
 package opennlp.tools.cmdline.depparse;
 
 import java.io.File;
-import java.io.IOException;
-import java.io.PrintWriter;
 
 import opennlp.tools.cmdline.BasicCmdLineTool;
 import opennlp.tools.cmdline.CLI;
-import opennlp.tools.cmdline.CmdLineUtil;
-import opennlp.tools.cmdline.SystemInputStreamFactory;
 import opennlp.tools.cmdline.TerminateToolException;
 import opennlp.tools.depparse.DependencyParserME;
-import opennlp.tools.depparse.DependencyTree;
-import opennlp.tools.postag.POSSample;
-import opennlp.tools.util.ObjectStream;
-import opennlp.tools.util.PlainTextByLineStream;
-import opennlp.tools.util.StringUtil;
 
 /** Parses POS-tagged sentences from standard input and prints basic CoNLL-U 
trees. */
 public class DependencyParserMETool extends BasicCmdLineTool {
 
-  /** Separates the fields of a CoNLL-U token row. */
-  private static final String FIELD_SEPARATOR = "\t";
-
-  /** Fills a CoNLL-U field that the parser does not predict. */
-  private static final String EMPTY_FIELD = "_";
-
   /** {@inheritDoc} */
   @Override
   public String getShortDescription() {
@@ -60,30 +45,7 @@ public class DependencyParserMETool extends BasicCmdLineTool 
{
     if (args.length != 1) {
       throw new TerminateToolException(1, getHelp());
     }
-    DependencyParserME parser = new DependencyParserME(new 
DependencyModelLoader().load(new File(args[0])));
-    PrintWriter out = new PrintWriter(System.out, true);
-    try (ObjectStream<String> lines = new PlainTextByLineStream(
-        new SystemInputStreamFactory(), SystemInputStreamFactory.encoding())) {
-      String line;
-      while ((line = lines.read()) != null) {
-        if (StringUtil.isBlank(line)) {
-          continue;
-        }
-        POSSample sample = POSSample.parse(line);
-        String[] tokens = sample.getSentence();
-        String[] tags = sample.getTags();
-        DependencyTree tree = parser.parse(tokens, tags);
-        for (int i = 0; i < tokens.length; i++) {
-          // Input tags may be UPOS or XPOS; retain them in XPOS without 
guessing a mapping.
-          out.println(String.join(FIELD_SEPARATOR, Integer.toString(i + 1), 
tokens[i],
-              EMPTY_FIELD, EMPTY_FIELD, tags[i], EMPTY_FIELD,
-              Integer.toString(tree.headOf(i) + 1), tree.relationOf(i),
-              EMPTY_FIELD, EMPTY_FIELD));
-        }
-        out.println();
-      }
-    } catch (IOException e) {
-      CmdLineUtil.handleStdinIoError(e);
-    }
+    DependencyParserToolSupport.parseTaggedSentences(
+        new DependencyParserME(new DependencyModelLoader().load(new 
File(args[0]))));
   }
 }
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserToolSupport.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserToolSupport.java
new file mode 100644
index 000000000..83b5d836c
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserToolSupport.java
@@ -0,0 +1,149 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.function.Supplier;
+
+import org.slf4j.Logger;
+
+import opennlp.tools.cmdline.CmdLineUtil;
+import opennlp.tools.cmdline.SystemInputStreamFactory;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.depparse.DependencyEvaluationMonitor;
+import opennlp.tools.depparse.DependencyEvaluator;
+import opennlp.tools.depparse.DependencyParser;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.depparse.DependencyTree;
+import opennlp.tools.depparse.FeedforwardDependencyModel;
+import opennlp.tools.depparse.FeedforwardDependencyParser;
+import opennlp.tools.postag.POSSample;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.PlainTextByLineStream;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.TrainingParameters;
+
+/** The parsing, evaluation, and parameter steps shared by the dependency 
parser tools. */
+final class DependencyParserToolSupport {
+
+  /** Separates the fields of a CoNLL-U token row. */
+  private static final String FIELD_SEPARATOR = "\t";
+
+  /** Fills a CoNLL-U field that the parser does not predict. */
+  private static final String EMPTY_FIELD = "_";
+
+  private DependencyParserToolSupport() {
+  }
+
+  /**
+   * Loads the training parameters file named on the command line, falling 
back to
+   * {@code fallback} when none is given.
+   *
+   * @param paramsFile The parameters file name, or {@code null} if none was 
given.
+   * @param fallback The parameters to use without a file. Must not be {@code 
null}.
+   * @return The training parameters. Never {@code null}.
+   */
+  static TrainingParameters loadTrainingParameters(String paramsFile,
+      TrainingParameters fallback) {
+    TrainingParameters loaded = CmdLineUtil.loadTrainingParameters(paramsFile, 
false);
+    return loaded == null ? fallback : loaded;
+  }
+
+  /**
+   * Creates a feedforward parser with the requested beam size, or the beam 
size of the
+   * model when none is requested.
+   *
+   * @param model The model to parse with. Must not be {@code null}.
+   * @param beamSize The beam size given on the command line, or {@code null}.
+   * @return The parser. Never {@code null}.
+   * @throws TerminateToolException Thrown if the beam size is not positive.
+   */
+  static FeedforwardDependencyParser 
createFeedforwardParser(FeedforwardDependencyModel model,
+      Integer beamSize) {
+    try {
+      return beamSize == null
+          ? new FeedforwardDependencyParser(model) : new 
FeedforwardDependencyParser(model, beamSize);
+    } catch (IllegalArgumentException e) {
+      throw new TerminateToolException(-1, e.getMessage(), e);
+    }
+  }
+
+  /**
+   * Parses the POS-tagged sentences on standard input, one per line in the
+   * {@code token_TAG} format, and prints each tree in basic CoNLL-U followed 
by a blank
+   * line. Blank input lines are skipped.
+   *
+   * @param parser The parser to apply. Must not be {@code null}.
+   */
+  static void parseTaggedSentences(DependencyParser parser) {
+    PrintWriter out = new PrintWriter(System.out, true);
+    try (ObjectStream<String> lines = new PlainTextByLineStream(
+        new SystemInputStreamFactory(), SystemInputStreamFactory.encoding())) {
+      String line;
+      while ((line = lines.read()) != null) {
+        if (StringUtil.isBlank(line)) {
+          continue;
+        }
+        POSSample sample = POSSample.parse(line);
+        String[] tokens = sample.getSentence();
+        String[] tags = sample.getTags();
+        DependencyTree tree = parser.parse(tokens, tags);
+        for (int i = 0; i < tokens.length; i++) {
+          // Input tags may be UPOS or XPOS; retain them in XPOS without 
guessing a mapping.
+          out.println(String.join(FIELD_SEPARATOR, Integer.toString(i + 1), 
tokens[i],
+              EMPTY_FIELD, EMPTY_FIELD, tags[i], EMPTY_FIELD,
+              Integer.toString(tree.headOf(i) + 1), tree.relationOf(i),
+              EMPTY_FIELD, EMPTY_FIELD));
+        }
+        out.println();
+      }
+    } catch (IOException e) {
+      CmdLineUtil.handleStdinIoError(e);
+    }
+  }
+
+  /**
+   * Scores a parser on samples and logs the attachment scores over all tokens 
and over
+   * the tokens that are not punctuation. Closes the samples, also when the 
parser
+   * cannot be created.
+   *
+   * @param parser Creates the parser to score, typically by loading its 
model. Must not
+   *               be {@code null}.
+   * @param samples The gold samples. Must not be {@code null}.
+   * @param misclassified Whether to print each misparsed sentence next to its 
gold tree.
+   * @param logger The logger to report the scores to. Must not be {@code 
null}.
+   * @throws IOException Thrown if reading the samples fails.
+   */
+  static void evaluate(Supplier<DependencyParser> parser, 
ObjectStream<DependencySample> samples,
+      boolean misclassified, Logger logger) throws IOException {
+    try (samples) {
+      DependencyEvaluationMonitor errorListener = null;
+      if (misclassified) {
+        errorListener = new DependencyEvaluationErrorListener();
+      }
+      DependencyEvaluator evaluator = new DependencyEvaluator(parser.get(), 
errorListener);
+      evaluator.evaluate(samples);
+      logger.info("Tokens: {}; UAS: {}; LAS: {}", evaluator.getWordCount(),
+          evaluator.getUas(), evaluator.getLas());
+      logger.info("Tokens excluding punctuation: {}; UAS: {}; LAS: {}",
+          evaluator.getWordCountExcludingPunctuation(),
+          evaluator.getUasExcludingPunctuation(), 
evaluator.getLasExcludingPunctuation());
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java
index 09bf7d590..d0cfacdf9 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java
@@ -58,23 +58,12 @@ public class DependencyParserTrainerTool extends 
AbstractTrainerTool<DependencyS
     super.run(format, args);
     try (ObjectStream<DependencySample> samples = sampleStream) {
       CmdLineUtil.checkOutputFile("dependency parser model", 
params.getModel());
-      mlParams = loadTrainingParameters(params.getParams());
+      mlParams = 
DependencyParserToolSupport.loadTrainingParameters(params.getParams(),
+          TrainingParameters.defaultParams());
       DependencyModel model = DependencyParserME.train(params.getLang(), 
samples, mlParams);
       CmdLineUtil.writeModel("dependency parser", params.getModel(), model);
     } catch (IOException e) {
       throw createTerminationIOException(e);
     }
   }
-
-  /**
-   * Loads the training parameters file named on the command line, falling 
back to the
-   * default parameters when none is given.
-   *
-   * @param paramsFile The parameters file name, or {@code null} if none was 
given.
-   * @return The training parameters. Never {@code null}.
-   */
-  static TrainingParameters loadTrainingParameters(String paramsFile) {
-    TrainingParameters loaded = CmdLineUtil.loadTrainingParameters(paramsFile, 
false);
-    return loaded == null ? TrainingParameters.defaultParams() : loaded;
-  }
 }
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyModelLoader.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyModelLoader.java
new file mode 100644
index 000000000..099b2948d
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyModelLoader.java
@@ -0,0 +1,46 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import opennlp.tools.cmdline.ModelLoader;
+import opennlp.tools.commons.Internal;
+import opennlp.tools.depparse.FeedforwardDependencyModel;
+
+/**
+ * Loads a {@link FeedforwardDependencyModel} for the command line tools.
+ * <p>
+ * <b>Note:</b> Do not use this class, internal use only!
+ */
+@Internal
+public class FeedforwardDependencyModelLoader extends 
ModelLoader<FeedforwardDependencyModel> {
+
+  /** Initializes a loader that names the model "Feedforward dependency 
parser" in its messages. */
+  public FeedforwardDependencyModelLoader() {
+    super("Feedforward dependency parser");
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  protected FeedforwardDependencyModel loadModel(InputStream modelIn) throws 
IOException {
+    return new FeedforwardDependencyModel(modelIn);
+  }
+
+}
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserEvaluatorTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserEvaluatorTool.java
new file mode 100644
index 000000000..fb6efb102
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserEvaluatorTool.java
@@ -0,0 +1,77 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.AbstractEvaluatorTool;
+import opennlp.tools.cmdline.ArgumentParser.OptionalParameter;
+import opennlp.tools.cmdline.ArgumentParser.ParameterDescription;
+import 
opennlp.tools.cmdline.depparse.FeedforwardDependencyParserEvaluatorTool.EvalToolParams;
+import opennlp.tools.cmdline.params.EvaluatorParams;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.depparse.FeedforwardDependencyParser;
+
+/**
+ * Reports labeled and unlabeled attachment scores of a {@link 
FeedforwardDependencyParser}
+ * against gold trees.
+ */
+public class FeedforwardDependencyParserEvaluatorTool
+    extends AbstractEvaluatorTool<DependencySample, EvalToolParams> {
+
+  /** The evaluator parameters plus the beam size to parse with. */
+  interface EvalToolParams extends EvaluatorParams {
+
+    @ParameterDescription(valueName = "n", description = "the number of 
transition sequences "
+        + "to keep per step. Defaults to the beam size the model was refined 
with, or 1 "
+        + "(greedy) for a model that was not refined.")
+    @OptionalParameter
+    Integer getBeamSize();
+  }
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(FeedforwardDependencyParserEvaluatorTool.class);
+
+  /** Creates the evaluator tool. */
+  public FeedforwardDependencyParserEvaluatorTool() {
+    super(DependencySample.class, EvalToolParams.class);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Measures feedforward dependency parser attachment scores";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String format, String[] args) {
+    super.run(format, args);
+    try {
+      DependencyParserToolSupport.evaluate(
+          () -> DependencyParserToolSupport.createFeedforwardParser(
+              new FeedforwardDependencyModelLoader().load(params.getModel()), 
params.getBeamSize()),
+          sampleStream, params.getMisclassified(), logger);
+    } catch (IOException e) {
+      throw createTerminationIOException(e);
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserTool.java
new file mode 100644
index 000000000..fd240378d
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserTool.java
@@ -0,0 +1,84 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.File;
+
+import opennlp.tools.cmdline.BasicCmdLineTool;
+import opennlp.tools.cmdline.CLI;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.depparse.FeedforwardDependencyParser;
+
+/**
+ * Parses POS-tagged sentences from standard input with a {@link 
FeedforwardDependencyParser}
+ * and prints basic CoNLL-U trees.
+ */
+public class FeedforwardDependencyParserTool extends BasicCmdLineTool {
+
+  /** The option that sets the beam size. */
+  static final String BEAM_SIZE_OPTION = "-beamSize";
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Parses dependency trees from POS-tagged sentences with the 
feedforward parser";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getHelp() {
+    return "Usage: " + CLI.CMD + " " + getName() + " [" + BEAM_SIZE_OPTION + " 
n] model"
+        + " < tagged-sentences\n"
+        + BEAM_SIZE_OPTION + " n: keep n transition sequences per step. 
Defaults to the beam"
+        + " size the model was refined with, or 1 (greedy) for a model that 
was not refined.";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String[] args) {
+    final Integer beamSize;
+    final String modelFile;
+    if (args.length == 1) {
+      beamSize = null;
+      modelFile = args[0];
+    } else if (args.length == 3 && BEAM_SIZE_OPTION.equals(args[0])) {
+      beamSize = parseBeamSize(args[1]);
+      modelFile = args[2];
+    } else {
+      throw new TerminateToolException(1, getHelp());
+    }
+    DependencyParserToolSupport.parseTaggedSentences(
+        DependencyParserToolSupport.createFeedforwardParser(
+            new FeedforwardDependencyModelLoader().load(new File(modelFile)), 
beamSize));
+  }
+
+  /**
+   * Parses the {@value #BEAM_SIZE_OPTION} argument.
+   *
+   * @param value The {@value #BEAM_SIZE_OPTION} argument.
+   * @return The beam size. Not checked for range.
+   * @throws TerminateToolException Thrown if {@code value} is not a decimal 
integer.
+   */
+  private static int parseBeamSize(String value) {
+    try {
+      return Integer.parseInt(value);
+    } catch (NumberFormatException e) {
+      throw new TerminateToolException(1, BEAM_SIZE_OPTION + " must be an 
integer: " + value, e);
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserTrainerTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserTrainerTool.java
new file mode 100644
index 000000000..6ac0aca53
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserTrainerTool.java
@@ -0,0 +1,125 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.File;
+import java.io.IOException;
+
+import opennlp.tools.cmdline.AbstractTrainerTool;
+import opennlp.tools.cmdline.ArgumentParser.OptionalParameter;
+import opennlp.tools.cmdline.ArgumentParser.ParameterDescription;
+import opennlp.tools.cmdline.CmdLineUtil;
+import opennlp.tools.cmdline.TerminateToolException;
+import 
opennlp.tools.cmdline.depparse.FeedforwardDependencyParserTrainerTool.TrainerParams;
+import opennlp.tools.cmdline.params.TrainingToolParams;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.depparse.FeedforwardDependencyModel;
+import opennlp.tools.depparse.FeedforwardDependencyTrainer;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.TrainingParameters;
+
+/**
+ * Trains and saves a {@link FeedforwardDependencyModel}, optionally refining 
it with beam
+ * search, or refines an existing model.
+ *
+ * <p>The {@code -params} and {@code -refineParams} files hold the keys that
+ * {@link FeedforwardDependencyTrainer} reads; absent keys take the trainer's 
defaults.</p>
+ */
+public class FeedforwardDependencyParserTrainerTool
+    extends AbstractTrainerTool<DependencySample, TrainerParams> {
+
+  /** The training parameters plus the optional refinement step. */
+  interface TrainerParams extends TrainingToolParams {
+
+    @ParameterDescription(valueName = "paramsFile", description = "refines the 
model with beam "
+        + "search, reading the refinement parameters from this file.")
+    @OptionalParameter
+    String getRefineParams();
+
+    @ParameterDescription(valueName = "modelFile", description = "refines this 
model instead "
+        + "of training a new one. Requires " + REFINE_PARAMS_OPTION + ".")
+    @OptionalParameter
+    File getBaseModel();
+  }
+
+  /** The option that names the training parameters file. */
+  private static final String PARAMS_OPTION = "-params";
+
+  /** The option that names the refinement parameters file. */
+  private static final String REFINE_PARAMS_OPTION = "-refineParams";
+
+  /** The option that names the model to refine. */
+  private static final String BASE_MODEL_OPTION = "-baseModel";
+
+  /** Creates the trainer tool. */
+  public FeedforwardDependencyParserTrainerTool() {
+    super(DependencySample.class, TrainerParams.class);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Trains a feedforward dependency parser from CoNLL-U samples";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String format, String[] args) {
+    super.run(format, args);
+    try (ObjectStream<DependencySample> samples = sampleStream) {
+      if (params.getBaseModel() != null) {
+        if (params.getRefineParams() == null) {
+          throw new TerminateToolException(1,
+              BASE_MODEL_OPTION + " requires " + REFINE_PARAMS_OPTION);
+        }
+        if (params.getParams() != null) {
+          throw new TerminateToolException(1, PARAMS_OPTION + " does not apply 
with " + BASE_MODEL_OPTION
+              + "; refinement reads " + REFINE_PARAMS_OPTION);
+        }
+      }
+      CmdLineUtil.checkOutputFile("feedforward dependency parser model", 
params.getModel());
+      final TrainingParameters refineParams = params.getRefineParams() == null
+          ? null : 
CmdLineUtil.loadTrainingParameters(params.getRefineParams(), false);
+
+      FeedforwardDependencyModel model;
+      if (params.getBaseModel() != null) {
+        model = new 
FeedforwardDependencyModelLoader().load(params.getBaseModel());
+        if (!params.getLang().equals(model.getLanguage())) {
+          throw new TerminateToolException(1, "-lang " + params.getLang()
+              + " does not match the base model language " + 
model.getLanguage());
+        }
+      } else {
+        // Empty parameters give every key the default of 
FeedforwardDependencyTrainer.
+        mlParams = 
DependencyParserToolSupport.loadTrainingParameters(params.getParams(),
+            new TrainingParameters());
+        model = FeedforwardDependencyTrainer.train(params.getLang(), samples, 
mlParams);
+        if (refineParams != null) {
+          samples.reset();
+        }
+      }
+      if (refineParams != null) {
+        model = FeedforwardDependencyTrainer.refine(model, samples, 
refineParams);
+      }
+      CmdLineUtil.writeModel("feedforward dependency parser", 
params.getModel(), model);
+    } catch (IOException e) {
+      throw createTerminationIOException(e);
+    } catch (IllegalArgumentException | IllegalStateException e) {
+      throw new TerminateToolException(-1, e.getMessage(), e);
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java
 
b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java
index 9ff205367..aea77bcd6 100644
--- 
a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java
+++ 
b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java
@@ -17,27 +17,19 @@
 
 package opennlp.tools.cmdline.depparse;
 
-import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.PrintStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 
-import ch.qos.logback.classic.Level;
-import ch.qos.logback.classic.Logger;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.core.read.ListAppender;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
-import org.slf4j.LoggerFactory;
 
 import opennlp.tools.cmdline.CLI;
 import opennlp.tools.cmdline.StreamFactoryRegistry;
@@ -48,6 +40,18 @@ import opennlp.tools.depparse.DependencySample;
 import opennlp.tools.depparse.DependencyTree;
 import opennlp.tools.util.ObjectStream;
 
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.EXPECTED_REVERSED;
+import static opennlp.tools.cmdline.depparse.DependencyToolTestSupport.GOLD;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PARSED_SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PERFECT_SCORES;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PERFECT_SCORES_EXCLUDING_PUNCTUATION;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PREDICTED_SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.REPETITIONS;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.REVERSED_SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.UTF_8_REQUIRED;
+import static opennlp.tools.cmdline.depparse.DependencyToolTestSupport.logOf;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.outputOf;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -55,42 +59,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 /** Exercises CLI registration, CoNLL-U input, training, persistence and 
inference. */
 public class DependencyParserToolsTest {
 
-  /** A two-token sentence in CoNLL-U; the tests repeat it to build a corpus. 
*/
-  private static final String SENTENCE = """
-      1\tdogs\tdog\tNOUN\tNNS\t_\t2\tnsubj\t_\t_
-      2\trun\trun\tVERB\tVBP\t_\t0\troot\t_\t_
-
-      """;
-
-  /** How often {@link #SENTENCE} is repeated in the training data. */
-  private static final int REPETITIONS = 20;
-
-  /** The gold tree of {@link #SENTENCE}. */
-  private static final DependencyTree GOLD =
-      DependencyTree.of(new int[] {1, -1}, new String[] {"nsubj", "root"});
-
-  /** The sentence with the heads swapped, which a parser trained on {@link 
#SENTENCE} misparses. */
-  private static final String REVERSED_SENTENCE = """
-      1\tdogs\tdog\tNOUN\tNNS\t_\t0\troot\t_\t_
-      2\trun\trun\tVERB\tVBP\t_\t1\tobj\t_\t_
-
-      """;
-
-  /** The gold tree of {@link #REVERSED_SENTENCE}. */
+  /** The gold tree of {@link DependencyToolTestSupport#REVERSED_SENTENCE}. */
   private static final DependencyTree REVERSED =
       DependencyTree.of(new int[] {-1, 0}, new String[] {"root", "obj"});
 
-  /** The expected block the error listener prints for {@link 
#REVERSED_SENTENCE}. */
-  private static final String EXPECTED_REVERSED = "Expected: {\n"
-      + "1\tdogs\tNOUN\t0\troot" + System.lineSeparator()
-      + "2\trun\tVERB\t1\tobj" + System.lineSeparator() + "}";
-
-  /** The predicted block the error listener prints for the parse of {@link 
#SENTENCE}. */
-  private static final String PREDICTED_SENTENCE = "Predicted: {\n"
-      + "1\tdogs\tNOUN\t2\tnsubj" + System.lineSeparator()
-      + "2\trun\tVERB\t0\troot" + System.lineSeparator() + "}";
-
-  /** The sentence with the subject relabeled, so only a label differs from 
{@link #SENTENCE}. */
+  /**
+   * The sentence with the subject relabeled, so only a label differs from
+   * {@link DependencyToolTestSupport#SENTENCE}.
+   */
   private static final String RELABELED_SENTENCE = """
       1\tdogs\tdog\tNOUN\tNNS\t_\t2\tnmod\t_\t_
       2\trun\trun\tVERB\tVBP\t_\t0\troot\t_\t_
@@ -105,16 +81,6 @@ public class DependencyParserToolsTest {
   /** The message prefix of a tool ended by an unusable argument value. */
   private static final String INVALID_ARGUMENT = "Invalid argument: ";
 
-  /** The message of the CoNLL-U format for an encoding other than UTF-8. */
-  private static final String UTF_8_REQUIRED = "CoNLL-U data must use UTF-8";
-
-  /** The scores logged for a parser that reproduces all 40 tokens of the 
data. */
-  private static final String PERFECT_SCORES = "Tokens: 40; UAS: 1.0; LAS: 
1.0";
-
-  /** The punctuation-free scores logged for the same parser; the data has no 
punctuation. */
-  private static final String PERFECT_SCORES_EXCLUDING_PUNCTUATION =
-      "Tokens excluding punctuation: 40; UAS: 1.0; LAS: 1.0";
-
   @TempDir
   private Path dir;
 
@@ -134,29 +100,6 @@ public class DependencyParserToolsTest {
         "-lang", "eng", "-data", data.toString(), "-model", model.toString()});
   }
 
-  /**
-   * Runs a tool while capturing the INFO messages its logger emits.
-   *
-   * @param tool The tool class whose logger is captured.
-   * @param run Runs the tool.
-   * @return The formatted log messages, in order. Never {@code null}.
-   */
-  private static List<String> logOf(Class<?> tool, Runnable run) {
-    final Logger logger = (Logger) LoggerFactory.getLogger(tool);
-    final Level previous = logger.getLevel();
-    final ListAppender<ILoggingEvent> appender = new ListAppender<>();
-    appender.start();
-    logger.addAppender(appender);
-    logger.setLevel(Level.INFO);
-    try {
-      run.run();
-    } finally {
-      logger.setLevel(previous);
-      logger.detachAppender(appender);
-    }
-    return 
appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
-  }
-
   @Test
   void testToolsAreRegistered() {
     assertTrue(CLI.getToolNames().contains("DependencyParserME"));
@@ -192,22 +135,11 @@ public class DependencyParserToolsTest {
   @Test
   void testParserToolPrintsConllu() {
     train();
-    final InputStream previousIn = System.in;
-    final PrintStream previousOut = System.out;
-    final ByteArrayOutputStream output = new ByteArrayOutputStream();
-    try {
-      System.setIn(new ByteArrayInputStream("dogs_NOUN 
run_VERB\n".getBytes(StandardCharsets.UTF_8)));
-      System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8));
-      new DependencyParserMETool().run(new String[] {model.toString()});
-    } finally {
-      System.setIn(previousIn);
-      System.setOut(previousOut);
-    }
-    assertEquals("1\tdogs\t_\t_\tNOUN\t_\t2\tnsubj\t_\t_\n"
-        + "2\trun\t_\t_\tVERB\t_\t0\troot\t_\t_\n\n",
-        output.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"));
+    assertEquals(PARSED_SENTENCE, outputOf("dogs_NOUN run_VERB\n",
+        () -> new DependencyParserMETool().run(new String[] 
{model.toString()})));
   }
 
+
   @Test
   void testConlluFormatReadsTheSelectedTagset() throws IOException {
     final var factory = 
StreamFactoryRegistry.getFactory(DependencySample.class, "conllu");
diff --git 
a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyToolTestSupport.java
 
b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyToolTestSupport.java
new file mode 100644
index 000000000..dd65d9ea5
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyToolTestSupport.java
@@ -0,0 +1,134 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.depparse.DependencyTree;
+
+/**
+ * The CoNLL-U data, expected output, and output and log capture shared by the 
dependency
+ * parser tool tests.
+ */
+final class DependencyToolTestSupport {
+
+  private DependencyToolTestSupport() {
+  }
+
+  /** A two-token sentence in CoNLL-U; the tests repeat it to build a corpus. 
*/
+  static final String SENTENCE = """
+      1\tdogs\tdog\tNOUN\tNNS\t_\t2\tnsubj\t_\t_
+      2\trun\trun\tVERB\tVBP\t_\t0\troot\t_\t_
+
+      """;
+
+  /** How often {@link #SENTENCE} is repeated in the training data. */
+  static final int REPETITIONS = 20;
+
+  /** The gold tree of {@link #SENTENCE}. */
+  static final DependencyTree GOLD =
+      DependencyTree.of(new int[] {1, -1}, new String[] {"nsubj", "root"});
+
+  /** The sentence with the heads swapped, which a parser trained on {@link 
#SENTENCE} misparses. */
+  static final String REVERSED_SENTENCE = """
+      1\tdogs\tdog\tNOUN\tNNS\t_\t0\troot\t_\t_
+      2\trun\trun\tVERB\tVBP\t_\t1\tobj\t_\t_
+
+      """;
+
+  /** The expected block the error listener prints for {@link 
#REVERSED_SENTENCE}. */
+  static final String EXPECTED_REVERSED = "Expected: {\n"
+      + "1\tdogs\tNOUN\t0\troot" + System.lineSeparator()
+      + "2\trun\tVERB\t1\tobj" + System.lineSeparator() + "}";
+
+  /** The predicted block the error listener prints for the parse of {@link 
#SENTENCE}. */
+  static final String PREDICTED_SENTENCE = "Predicted: {\n"
+      + "1\tdogs\tNOUN\t2\tnsubj" + System.lineSeparator()
+      + "2\trun\tVERB\t0\troot" + System.lineSeparator() + "}";
+
+  /** The CoNLL-U output of the parser tools for {@code dogs_NOUN run_VERB}. */
+  static final String PARSED_SENTENCE = 
"1\tdogs\t_\t_\tNOUN\t_\t2\tnsubj\t_\t_\n"
+      + "2\trun\t_\t_\tVERB\t_\t0\troot\t_\t_\n\n";
+
+  /** The scores logged for a parser that reproduces all 40 tokens of the 
data. */
+  static final String PERFECT_SCORES = "Tokens: 40; UAS: 1.0; LAS: 1.0";
+
+  /** The punctuation-free scores logged for the same parser; the data has no 
punctuation. */
+  static final String PERFECT_SCORES_EXCLUDING_PUNCTUATION =
+      "Tokens excluding punctuation: 40; UAS: 1.0; LAS: 1.0";
+
+  /** The message of the CoNLL-U format for an encoding other than UTF-8. */
+  static final String UTF_8_REQUIRED = "CoNLL-U data must use UTF-8";
+
+  /**
+   * Runs a tool while capturing the INFO messages its logger emits.
+   *
+   * @param tool The tool class whose logger is captured.
+   * @param run Runs the tool.
+   * @return The formatted log messages, in order. Never {@code null}.
+   */
+  static List<String> logOf(Class<?> tool, Runnable run) {
+    final Logger logger = (Logger) LoggerFactory.getLogger(tool);
+    final Level previous = logger.getLevel();
+    final ListAppender<ILoggingEvent> appender = new ListAppender<>();
+    appender.start();
+    logger.addAppender(appender);
+    logger.setLevel(Level.INFO);
+    try {
+      run.run();
+    } finally {
+      logger.setLevel(previous);
+      logger.detachAppender(appender);
+    }
+    return 
appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
+  }
+
+  /**
+   * Runs a tool with the given standard input while capturing its standard 
output.
+   *
+   * @param input The standard input, encoded as UTF-8.
+   * @param run Runs the tool.
+   * @return The standard output, with line breaks normalized to {@code \n}. 
Never
+   *         {@code null}.
+   */
+  static String outputOf(String input, Runnable run) {
+    final InputStream previousIn = System.in;
+    final PrintStream previousOut = System.out;
+    final ByteArrayOutputStream output = new ByteArrayOutputStream();
+    try {
+      System.setIn(new 
ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)));
+      System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8));
+      run.run();
+    } finally {
+      System.setIn(previousIn);
+      System.setOut(previousOut);
+    }
+    return output.toString(StandardCharsets.UTF_8).replace("\r\n", "\n");
+  }
+}
diff --git 
a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserToolsTest.java
 
b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserToolsTest.java
new file mode 100644
index 000000000..d6db96052
--- /dev/null
+++ 
b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/FeedforwardDependencyParserToolsTest.java
@@ -0,0 +1,581 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.cmdline.CLI;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.depparse.FeedforwardDependencyModel;
+import opennlp.tools.depparse.FeedforwardDependencyParser;
+import opennlp.tools.depparse.FeedforwardDependencyTrainer;
+import opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.model.BaseModel;
+
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.EXPECTED_REVERSED;
+import static opennlp.tools.cmdline.depparse.DependencyToolTestSupport.GOLD;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PARSED_SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PERFECT_SCORES;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PERFECT_SCORES_EXCLUDING_PUNCTUATION;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.PREDICTED_SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.REPETITIONS;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.REVERSED_SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.SENTENCE;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.UTF_8_REQUIRED;
+import static opennlp.tools.cmdline.depparse.DependencyToolTestSupport.logOf;
+import static 
opennlp.tools.cmdline.depparse.DependencyToolTestSupport.outputOf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Exercises the feedforward dependency parser tools: registration, training, 
refinement, inference. */
+public class FeedforwardDependencyParserToolsTest {
+
+  /**
+   * The training parameters of a small network that learns
+   * {@link DependencyToolTestSupport#SENTENCE} quickly.
+   */
+  private static final String TRAINING_PARAMETERS = """
+      EmbeddingSize=16
+      HiddenSize=32
+      Iterations=30
+      BatchSize=32
+      LearningRate=0.1
+      L2Cost=0
+      Dropout=0
+      Cutoff=1
+      """;
+
+  /** The refinement parameters: one pass with a beam of three. */
+  private static final String REFINE_PARAMETERS = """
+      Iterations=1
+      LearningRate=0.01
+      BeamSize=3
+      """;
+
+  @TempDir
+  private Path dir;
+
+  private Path data;
+  private Path model;
+  private Path trainingParameters;
+  private Path refineParameters;
+
+  @BeforeEach
+  void writeData() throws IOException {
+    data = dir.resolve("train.conllu");
+    model = dir.resolve("parser.bin");
+    trainingParameters = dir.resolve("train.params");
+    refineParameters = dir.resolve("refine.params");
+    Files.writeString(data, SENTENCE.repeat(REPETITIONS));
+    Files.writeString(trainingParameters, TRAINING_PARAMETERS);
+    Files.writeString(refineParameters, REFINE_PARAMETERS);
+  }
+
+  /**
+   * Runs the trainer tool with the small training parameters plus extra 
arguments.
+   *
+   * @param extra Arguments appended after the common ones.
+   */
+  private void train(String... extra) {
+    final List<String> args = new ArrayList<>(List.of("-lang", "eng", "-data", 
data.toString(),
+        "-model", model.toString(), "-params", trainingParameters.toString()));
+    args.addAll(List.of(extra));
+    new FeedforwardDependencyParserTrainerTool().run("conllu", 
args.toArray(new String[0]));
+  }
+
+  /**
+   * Runs the parser tool on the given standard input.
+   *
+   * @param input The tagged sentences to parse.
+   * @param args The tool arguments.
+   * @return The standard output of the tool, with line breaks normalized to 
{@code \n}.
+   */
+  private static String parse(String input, String... args) {
+    return outputOf(input, () -> new 
FeedforwardDependencyParserTool().run(args));
+  }
+
+
+  /** The three feedforward tools are registered with the command line. */
+  @Test
+  void testToolsAreRegistered() {
+    assertTrue(CLI.getToolNames().contains("FeedforwardDependencyParser"));
+    
assertTrue(CLI.getToolNames().contains("FeedforwardDependencyParserTrainer"));
+    
assertTrue(CLI.getToolNames().contains("FeedforwardDependencyParserEvaluator"));
+  }
+
+  /** No feedforward cross validator exists in the API, so none is registered. 
*/
+  @Test
+  void testNoCrossValidatorIsRegistered() {
+    
assertFalse(CLI.getToolNames().contains("FeedforwardDependencyParserCrossValidator"));
+  }
+
+  /**
+   * The trainer writes a model that loads, parses the training sentence, and 
records the
+   * training parameters.
+   */
+  @Test
+  void testTrainerWritesALoadableModel() throws IOException {
+    train();
+    final FeedforwardDependencyModel loaded = new 
FeedforwardDependencyModel(model);
+    assertEquals("eng", loaded.getLanguage());
+    assertEquals(GOLD, new FeedforwardDependencyParser(loaded)
+        .parse(new String[] {"dogs", "run"}, new String[] {"NOUN", "VERB"}));
+    assertEquals("30", 
loaded.getManifestProperty(BaseModel.TRAINING_ITERATIONS_PROPERTY));
+    assertEquals("1", 
loaded.getManifestProperty(BaseModel.TRAINING_CUTOFF_PROPERTY));
+    assertNull(loaded.getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER));
+  }
+
+  /**
+   * Without {@code -params} the trainer's own defaults apply, not the maxent 
defaults
+   * of {@code TrainingParameters.defaultParams()}.
+   */
+  @Test
+  void testTrainerWithoutParamsUsesTheTrainerDefaults() throws IOException {
+    new FeedforwardDependencyParserTrainerTool().run("conllu", new String[] {
+        "-lang", "eng", "-data", data.toString(), "-model", model.toString()});
+    final FeedforwardDependencyModel loaded = new 
FeedforwardDependencyModel(model);
+    assertEquals(Integer.toString(FeedforwardDependencyTrainer.DEFAULT_EPOCHS),
+        loaded.getManifestProperty(BaseModel.TRAINING_ITERATIONS_PROPERTY));
+    
assertEquals(Integer.toString(FeedforwardDependencyTrainer.DEFAULT_WORD_CUTOFF),
+        loaded.getManifestProperty(BaseModel.TRAINING_CUTOFF_PROPERTY));
+  }
+
+  /** With {@code -refineParams} the trained model is refined and records the 
refinement beam size. */
+  @Test
+  void testTrainerRefinesWithTheRefineParameters() throws IOException {
+    train("-refineParams", refineParameters.toString());
+    final FeedforwardDependencyModel loaded = new 
FeedforwardDependencyModel(model);
+    assertEquals("3", 
loaded.getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER));
+    assertEquals("1", 
loaded.getManifestProperty(BaseModel.TRAINING_ITERATIONS_PROPERTY));
+    assertEquals(GOLD, new FeedforwardDependencyParser(loaded, 3)
+        .parse(new String[] {"dogs", "run"}, new String[] {"NOUN", "VERB"}));
+  }
+
+  /** With {@code -baseModel} the trainer refines a saved model instead of 
training one. */
+  @Test
+  void testTrainerRefinesAnExistingModel() throws IOException {
+    train();
+    final Path refined = dir.resolve("refined.bin");
+    new FeedforwardDependencyParserTrainerTool().run("conllu", new String[] {
+        "-lang", "eng", "-data", data.toString(), "-model", refined.toString(),
+        "-baseModel", model.toString(), "-refineParams", 
refineParameters.toString()});
+    final FeedforwardDependencyModel loaded = new 
FeedforwardDependencyModel(refined);
+    assertEquals("3", 
loaded.getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER));
+    assertEquals(GOLD, new FeedforwardDependencyParser(loaded, 3)
+        .parse(new String[] {"dogs", "run"}, new String[] {"NOUN", "VERB"}));
+  }
+
+  /** {@code -baseModel} without {@code -refineParams} is rejected as an 
argument error. */
+  @Test
+  void testTrainerRejectsBaseModelWithoutRefineParams() {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserTrainerTool().run("conllu", new 
String[] {
+            "-lang", "eng", "-data", data.toString(), "-model", 
dir.resolve("out.bin").toString(),
+            "-baseModel", model.toString()}));
+    assertEquals(1, exception.getCode());
+    assertEquals("-baseModel requires -refineParams", exception.getMessage());
+  }
+
+  /** {@code -params} together with {@code -baseModel} is rejected, because no 
training runs. */
+  @Test
+  void testTrainerRejectsBaseModelWithParams() {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserTrainerTool().run("conllu", new 
String[] {
+            "-lang", "eng", "-data", data.toString(), "-model", 
dir.resolve("out.bin").toString(),
+            "-baseModel", model.toString(), "-refineParams", 
refineParameters.toString(),
+            "-params", trainingParameters.toString()}));
+    assertEquals(1, exception.getCode());
+    assertEquals("-params does not apply with -baseModel; refinement reads 
-refineParams",
+        exception.getMessage());
+  }
+
+  /** A {@code -lang} that differs from the language of the base model is 
rejected. */
+  @Test
+  void testTrainerRejectsBaseModelOfAnotherLanguage() {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserTrainerTool().run("conllu", new 
String[] {
+            "-lang", "spa", "-data", data.toString(), "-model", 
dir.resolve("out.bin").toString(),
+            "-baseModel", model.toString(), "-refineParams", 
refineParameters.toString()}));
+    assertEquals(1, exception.getCode());
+    assertEquals("-lang spa does not match the base model language eng", 
exception.getMessage());
+  }
+
+  /** Refinement needs a beam of at least two; the trainer's message ends the 
tool. */
+  @Test
+  void testTrainerRejectsRefinementBeamOfOne() throws IOException {
+    Files.writeString(refineParameters, "BeamSize=1\n");
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> train("-refineParams", refineParameters.toString()));
+    assertEquals(-1, exception.getCode());
+    assertEquals("BeamSize must be at least 2: 1", exception.getMessage());
+    assertFalse(Files.exists(model));
+  }
+
+  /** An out-of-range hyperparameter ends the tool with the trainer's message. 
*/
+  @Test
+  void testTrainerRejectsInvalidHyperparameter() throws IOException {
+    Files.writeString(trainingParameters, "HiddenSize=0\n");
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        this::train);
+    assertEquals(-1, exception.getCode());
+    assertEquals("HiddenSize must be positive", exception.getMessage());
+    assertFalse(Files.exists(model));
+  }
+
+  /** Data without any trainable sample ends the tool with the trainer's 
message. */
+  @Test
+  void testTrainerOnEmptyDataTerminates() throws IOException {
+    Files.writeString(data, "");
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        this::train);
+    assertEquals(-1, exception.getCode());
+    assertFalse(Files.exists(model));
+  }
+
+  /** The trainer passes an {@code -encoding} other than UTF-8 on to the 
format, which rejects it. */
+  @Test
+  void testTrainerRejectsOtherEncodings() {
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> train("-encoding", "ISO-8859-1"));
+    assertEquals(-1, exception.getCode());
+    assertEquals(UTF_8_REQUIRED, exception.getMessage());
+  }
+
+  /** The parser tool prints the greedy parse of a tagged line as CoNLL-U. */
+  @Test
+  void testParserToolPrintsConllu() {
+    train();
+    assertEquals(PARSED_SENTENCE, parse("dogs_NOUN run_VERB\n", 
model.toString()));
+  }
+
+  /** With {@code -beamSize} the parser tool parses every non-blank line and 
skips blank ones. */
+  @Test
+  void testParserToolParsesWithABeam() {
+    train();
+    assertEquals(PARSED_SENTENCE + PARSED_SENTENCE,
+        parse("dogs_NOUN run_VERB\n\n  \ndogs_NOUN run_VERB\n", "-beamSize", 
"4", model.toString()));
+  }
+
+  /**
+   * Lists the argument lists the parser tool rejects with its help text.
+   *
+   * @return Argument lists the parser tool does not accept.
+   */
+  static Stream<Arguments> wrongParserArguments() {
+    return Stream.of(
+        Arguments.of((Object) new String[0]),
+        Arguments.of((Object) new String[] {"-beamSize", "2"}),
+        Arguments.of((Object) new String[] {"-beamSize", "2", "a", "b"}),
+        Arguments.of((Object) new String[] {"-other", "2", "model"}),
+        Arguments.of((Object) new String[] {"model", "-beamSize", "2"}));
+  }
+
+  /** Argument lists other than {@code model} or {@code -beamSize n model} end 
the tool with its help text. */
+  @ParameterizedTest(name = "args = {0}")
+  @MethodSource("wrongParserArguments")
+  void testParserToolRejectsWrongArguments(String[] args) {
+    final FeedforwardDependencyParserTool tool = new 
FeedforwardDependencyParserTool();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> tool.run(args));
+    assertEquals(1, exception.getCode());
+    assertEquals(tool.getHelp(), exception.getMessage());
+  }
+
+  /** A {@code -beamSize} value that is not a decimal integer is rejected as 
an argument error. */
+  @ParameterizedTest(name = "beamSize = \"{0}\"")
+  @ValueSource(strings = {"abc", "2.5", "", " 2"})
+  void testParserToolRejectsNonIntegerBeamSize(String beamSize) {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserTool().run(new String[] {
+            "-beamSize", beamSize, model.toString()}));
+    assertEquals(1, exception.getCode());
+    assertEquals("-beamSize must be an integer: " + beamSize, 
exception.getMessage());
+  }
+
+  /** A {@code -beamSize} below one ends the tool with the parser's message. */
+  @ParameterizedTest(name = "beamSize = {0}")
+  @ValueSource(ints = {Integer.MIN_VALUE, -1, 0})
+  void testParserToolRejectsBeamSizeBelowOne(int beamSize) {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserTool().run(new String[] {
+            "-beamSize", Integer.toString(beamSize), model.toString()}));
+    assertEquals(-1, exception.getCode());
+    assertEquals("beamSize must be positive", exception.getMessage());
+  }
+
+  /** Without {@code -beamSize} the parser tool parses a refined model with 
the beam it records. */
+  @Test
+  void testParserToolParsesARefinedModel() {
+    train("-refineParams", refineParameters.toString());
+    assertEquals(PARSED_SENTENCE, parse("dogs_NOUN run_VERB\n", 
model.toString()));
+  }
+
+  /** The evaluator logs perfect greedy scores on the training data. */
+  @Test
+  void testEvaluatorLogsAttachmentScores() {
+    train();
+    final List<String> log = 
logOf(FeedforwardDependencyParserEvaluatorTool.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", data.toString()}));
+    assertEquals(List.of(PERFECT_SCORES, 
PERFECT_SCORES_EXCLUDING_PUNCTUATION), log);
+  }
+
+  /** The evaluator logs the same scores when it parses with a beam. */
+  @Test
+  void testEvaluatorLogsAttachmentScoresWithABeam() {
+    train();
+    final List<String> log = 
logOf(FeedforwardDependencyParserEvaluatorTool.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", data.toString(), "-beamSize", 
"4"}));
+    assertEquals(List.of(PERFECT_SCORES, 
PERFECT_SCORES_EXCLUDING_PUNCTUATION), log);
+  }
+
+  /** A {@code -beamSize} below one ends the evaluator with the parser's 
message. */
+  @Test
+  void testEvaluatorRejectsBeamSizeBelowOne() {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", data.toString(), "-beamSize", 
"0"}));
+    assertEquals(-1, exception.getCode());
+    assertEquals("beamSize must be positive", exception.getMessage());
+  }
+
+  /** With {@code -misclassified true} the evaluator prints a misparsed 
sentence next to its gold tree. */
+  @Test
+  void testEvaluatorPrintsMisparsedSamples() throws IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", test.toString(), 
"-misclassified", "true"}));
+    assertEquals(List.of(EXPECTED_REVERSED + "\n" + PREDICTED_SENTENCE), log);
+  }
+
+  /** Without {@code -misclassified} the evaluator prints no samples. */
+  @Test
+  void testEvaluatorWithoutMisclassifiedFlagPrintsNoSamples() throws 
IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", test.toString()}));
+    assertEquals(List.of(), log);
+  }
+
+  /** The feedforward loader rejects a maxent dependency model as an invalid 
format. */
+  @Test
+  void testModelLoaderRejectsAMaxentModel() {
+    new DependencyParserTrainerTool().run("conllu", new String[] {
+        "-lang", "eng", "-data", data.toString(), "-model", model.toString()});
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyModelLoader().load(model.toFile()));
+    assertEquals(-1, exception.getCode());
+    assertEquals("Model has invalid format", exception.getMessage());
+  }
+
+  /** The maxent loader rejects a feedforward model the same way, through the 
shared loader. */
+  @Test
+  void testMaxentModelLoaderRejectsAFeedforwardModel() {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new DependencyModelLoader().load(model.toFile()));
+    assertEquals(-1, exception.getCode());
+    assertEquals("Model has invalid format", exception.getMessage());
+  }
+
+  /** The feedforward loader loads a model the trainer wrote. */
+  @Test
+  void testModelLoaderLoadsAFeedforwardModel() {
+    train();
+    assertEquals("eng", new 
FeedforwardDependencyModelLoader().load(model.toFile()).getLanguage());
+  }
+
+  /** A {@code -params} file without {@code Iterations} trains for the 
trainer's default epochs. */
+  @Test
+  void testTrainerParamsWithoutIterationsUseTheTrainerDefault() throws 
IOException {
+    Files.writeString(trainingParameters, 
"EmbeddingSize=16\nHiddenSize=32\nDropout=0\nCutoff=1\n");
+    train();
+    assertEquals(Integer.toString(FeedforwardDependencyTrainer.DEFAULT_EPOCHS),
+        new FeedforwardDependencyModel(model).getManifestProperty(
+            BaseModel.TRAINING_ITERATIONS_PROPERTY));
+  }
+
+  /** A missing {@code -refineParams} file ends the tool before training and 
writes no model. */
+  @Test
+  void testTrainerRejectsMissingRefineParamsFile() {
+    final Path missing = dir.resolve("missing.params");
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> train("-refineParams", missing.toString()));
+    assertEquals(-1, exception.getCode());
+    assertEquals("The Training Parameter file does not exist! Path: " + 
missing.toAbsolutePath(),
+        exception.getMessage());
+    assertFalse(Files.exists(model));
+  }
+
+  /** A maxent model given as {@code -baseModel} ends the trainer through the 
shared loader. */
+  @Test
+  void testTrainerRejectsMaxentBaseModel() {
+    new DependencyParserTrainerTool().run("conllu", new String[] {
+        "-lang", "eng", "-data", data.toString(), "-model", model.toString()});
+    final Path out = dir.resolve("out.bin");
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserTrainerTool().run("conllu", new 
String[] {
+            "-lang", "eng", "-data", data.toString(), "-model", out.toString(),
+            "-baseModel", model.toString(), "-refineParams", 
refineParameters.toString()}));
+    assertEquals(-1, exception.getCode());
+    assertEquals("Model has invalid format", exception.getMessage());
+    assertFalse(Files.exists(out));
+  }
+
+  /** Empty standard input produces no output. */
+  @Test
+  void testParserToolOnEmptyInputPrintsNothing() {
+    train();
+    assertEquals("", parse("", model.toString()));
+  }
+
+  /** A token without a tag separator ends the parser tool with the standard 
input error. */
+  @Test
+  void testParserToolRejectsTokenWithoutTag() {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> parse("dogs run_VERB\n", model.toString()));
+    assertEquals(-1, exception.getCode());
+    assertEquals("IO Error while reading from stdin: Cannot find \"_\" inside 
token 'dogs'!",
+        exception.getMessage());
+  }
+
+  /** The help text names {@code -beamSize} and its default, so a change to it 
is deliberate. */
+  @Test
+  void testParserToolHelp() {
+    assertEquals("Usage: " + CLI.CMD + " FeedforwardDependencyParser 
[-beamSize n] model"
+        + " < tagged-sentences\n"
+        + "-beamSize n: keep n transition sequences per step. Defaults to the 
beam size the"
+        + " model was refined with, or 1 (greedy) for a model that was not 
refined.",
+        new FeedforwardDependencyParserTool().getHelp());
+  }
+
+  /** With a beam, {@code -misclassified true} prints the same misparsed 
sentence as greedy decoding. */
+  @Test
+  void testEvaluatorPrintsMisparsedSamplesWithABeam() throws IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", test.toString(), 
"-misclassified", "true",
+            "-beamSize", "4"}));
+    assertEquals(List.of(EXPECTED_REVERSED + "\n" + PREDICTED_SENTENCE), log);
+  }
+
+  /** A {@code -beamSize} value that is not a decimal integer ends the 
evaluator with code 1. */
+  @ParameterizedTest(name = "beamSize = \"{0}\"")
+  @ValueSource(strings = {"abc", "2.5", "", " 2"})
+  void testEvaluatorRejectsNonIntegerBeamSize(String beamSize) {
+    train();
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", data.toString(), "-beamSize", 
beamSize}));
+    assertEquals(1, exception.getCode());
+    assertTrue(exception.getMessage().startsWith("Invalid argument: -beamSize 
" + beamSize),
+        exception.getMessage());
+    assertTrue(exception.getMessage().endsWith("Value must be an integer!"),
+        exception.getMessage());
+  }
+
+  /** Empty test data scores zero tokens without failing. */
+  @Test
+  void testEvaluatorOnEmptyDataLogsZeroScores() throws IOException {
+    train();
+    final Path empty = dir.resolve("empty.conllu");
+    Files.writeString(empty, "");
+    final List<String> log = 
logOf(FeedforwardDependencyParserEvaluatorTool.class,
+        () -> new FeedforwardDependencyParserEvaluatorTool().run("conllu", new 
String[] {
+            "-model", model.toString(), "-data", empty.toString()}));
+    assertEquals(List.of("Tokens: 0; UAS: 0.0; LAS: 0.0",
+        "Tokens excluding punctuation: 0; UAS: 0.0; LAS: 0.0"), log);
+  }
+
+  /** A model whose manifest records a beam size that is not an integer fails 
to load. */
+  @Test
+  void testParserToolRejectsInvalidRecordedBeamSize() throws IOException {
+    train("-refineParams", refineParameters.toString());
+    final Path tampered = dir.resolve("tampered.bin");
+    replaceInManifest(model, tampered, "BeamSize=3", "BeamSize=abc");
+    final TerminateToolException exception = 
assertThrows(TerminateToolException.class,
+        () -> parse("dogs_NOUN run_VERB\n", tampered.toString()));
+    assertEquals(-1, exception.getCode());
+    assertEquals("Model has invalid format", exception.getMessage());
+  }
+
+  /**
+   * Copies a model package and replaces text in its manifest entry.
+   *
+   * @param source The model to copy.
+   * @param target The file to write the changed model to.
+   * @param from The manifest text to replace. Must occur in the manifest.
+   * @param to The replacement text.
+   * @throws IOException Thrown if reading or writing a model fails.
+   */
+  private static void replaceInManifest(Path source, Path target, String from, 
String to)
+      throws IOException {
+    try (ZipInputStream in = new ZipInputStream(Files.newInputStream(source));
+         ZipOutputStream out = new 
ZipOutputStream(Files.newOutputStream(target))) {
+      ZipEntry entry;
+      while ((entry = in.getNextEntry()) != null) {
+        byte[] content = in.readAllBytes();
+        if ("manifest.properties".equals(entry.getName())) {
+          final String manifest = new String(content, StandardCharsets.UTF_8);
+          assertTrue(manifest.contains(from), manifest);
+          content = manifest.replace(from, 
to).getBytes(StandardCharsets.UTF_8);
+        }
+        out.putNextEntry(new ZipEntry(entry.getName()));
+        out.write(content);
+        out.closeEntry();
+      }
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java
index 5556d1fba..e0bec1d6f 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java
@@ -24,6 +24,7 @@ import java.io.Serial;
 import java.nio.file.Path;
 import java.util.Map;
 
+import opennlp.tools.ml.BeamSearch;
 import opennlp.tools.util.InvalidFormatException;
 import opennlp.tools.util.ParamChecks;
 import opennlp.tools.util.model.ArtifactSerializer;
@@ -45,7 +46,7 @@ import opennlp.tools.util.model.BaseModel;
 public class FeedforwardDependencyModel extends BaseModel {
 
   @Serial
-  private static final long serialVersionUID = 2746091883120417596L;
+  private static final long serialVersionUID = -5262211380538922987L;
 
   /** The component name recorded in the model manifest. */
   private static final String COMPONENT_NAME = "FeedforwardDependencyParser";
@@ -56,6 +57,9 @@ public class FeedforwardDependencyModel extends BaseModel {
   /** The artifact extension the network serializer is registered under. */
   private static final String NETWORK_EXTENSION = "network";
 
+  /** The beam size of a model that was not refined with beam search. */
+  private static final int GREEDY_BEAM_SIZE = 1;
+
   /**
    * Initializes a {@link FeedforwardDependencyModel} from a trained network.
    *
@@ -129,7 +133,8 @@ public class FeedforwardDependencyModel extends BaseModel {
    * {@inheritDoc}
    *
    * @throws InvalidFormatException Thrown if the network artifact is missing 
or not a
-   *         feedforward network.
+   *         feedforward network, or the manifest records a beam size that is 
not a
+   *         positive integer.
    */
   @Override
   protected void validateArtifactMap() throws InvalidFormatException {
@@ -138,6 +143,38 @@ public class FeedforwardDependencyModel extends BaseModel {
       throw new InvalidFormatException("The " + NETWORK_ENTRY_NAME
           + " artifact is missing or not a feedforward network.");
     }
+    final String beamSize = 
getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER);
+    if (beamSize != null && !isPositiveInteger(beamSize)) {
+      throw new InvalidFormatException("The manifest " + 
BeamSearch.BEAM_SIZE_PARAMETER
+          + " must be a positive integer: " + beamSize);
+    }
+  }
+
+  /**
+   * Returns the beam size a parser uses when none is given: the beam size the 
model was
+   * refined with, which the manifest records under
+   * {@value BeamSearch#BEAM_SIZE_PARAMETER}, or {@code 1} (greedy) for a 
model that was
+   * not refined.
+   *
+   * @return The beam size. Always positive.
+   */
+  public int getBeamSize() {
+    final String beamSize = 
getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER);
+    return beamSize == null ? GREEDY_BEAM_SIZE : Integer.parseInt(beamSize);
+  }
+
+  /**
+   * Tells whether a manifest value is a positive decimal integer.
+   *
+   * @param value The manifest value. Must not be {@code null}.
+   * @return {@code true} if {@code value} parses as an {@code int} above zero.
+   */
+  private static boolean isPositiveInteger(String value) {
+    try {
+      return Integer.parseInt(value) > 0;
+    } catch (NumberFormatException e) {
+      return false;
+    }
   }
 
   /**
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java
index 206dcbc74..f0245a30f 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyParser.java
@@ -24,8 +24,9 @@ import opennlp.tools.util.ParamChecks;
 
 /**
  * The neural {@link DependencyParser}: an arc-standard decoder over the
- * {@link FeedforwardDependencyModel}, greedy by default and beamed when 
constructed
- * with a beam size above one. It scores configurations with learned dense
+ * {@link FeedforwardDependencyModel}. By default it decodes with the beam 
size the
+ * model was refined with, or greedily for a model that was not refined; a 
beam size
+ * given to the constructor overrides that. It scores configurations with 
learned dense
  * representations instead of the sparse feature conjunctions of
  * {@link DependencyParserME}.
  *
@@ -56,13 +57,15 @@ public class FeedforwardDependencyParser implements 
DependencyParser {
   private final ContributionCache cache;
 
   /**
-   * Initializes a greedy {@link FeedforwardDependencyParser} without a 
scoring cache.
+   * Initializes a {@link FeedforwardDependencyParser} with the beam size of 
the model
+   * and without a scoring cache.
    *
    * @param model The model to parse with. Must not be {@code null}.
    * @throws IllegalArgumentException Thrown if {@code model} is {@code null}.
+   * @see FeedforwardDependencyModel#getBeamSize()
    */
   public FeedforwardDependencyParser(FeedforwardDependencyModel model) {
-    this(model, GREEDY_BEAM_SIZE);
+    this(model, ParamChecks.requireNonNullArg(model, "model").getBeamSize());
   }
 
   /**
@@ -182,6 +185,15 @@ public class FeedforwardDependencyParser implements 
DependencyParser {
     return beam.get(0).state.toTree();
   }
 
+  /**
+   * Returns the number of transition sequences the parser retains.
+   *
+   * @return The beam size. Always positive; {@code 1} decodes greedily.
+   */
+  int beamSize() {
+    return beamSize;
+  }
+
   /**
    * @return The scoring cache, or {@code null} if the parser was created 
without one.
    */
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java
index 6508cb0a8..4298aa017 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java
@@ -31,6 +31,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.TreeSet;
+import java.util.function.UnaryOperator;
 import java.util.stream.Stream;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
@@ -43,6 +44,7 @@ import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import opennlp.tools.ml.BeamSearch;
 import opennlp.tools.ml.maxent.quasinewton.QNTrainer;
@@ -115,6 +117,9 @@ public class FeedforwardDependencyParserTest {
   private static final int OVERSIZED_HIDDEN_ROWS = 
Math.min(ResourceLimits.MAX_ENTRIES,
       ResourceLimits.MAX_MATRIX_CELLS / FeedforwardContext.FEATURE_COUNT + 1);
 
+  /** The model package entry that holds the manifest. */
+  private static final String MANIFEST_ENTRY_NAME = "manifest.properties";
+
   /** A symbol of 70000 bytes, longer than an unsigned 16-bit length can hold. 
*/
   private static final String LONG_SYMBOL = "x".repeat(70_000);
 
@@ -271,6 +276,83 @@ public class FeedforwardDependencyParserTest {
     assertEquals(LANGUAGE, refined.getLanguage());
   }
 
+  /** Checks that a model that was not refined records no beam size and parses 
greedily. */
+  @Test
+  void testUnrefinedModelParsesGreedilyByDefault() {
+    assertNull(model.getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER));
+    assertEquals(1, model.getBeamSize());
+    assertEquals(1, parser.beamSize());
+  }
+
+  /**
+   * Checks that a parser created without a beam size uses the beam size the 
model was
+   * refined with, also after the model is serialized and loaded again.
+   */
+  @Test
+  void testRefinedModelParsesWithItsBeamSizeByDefault() throws IOException {
+    final TrainingParameters parameters = feedforwardParameters(1, 0.01);
+    parameters.put(BeamSearch.BEAM_SIZE_PARAMETER, 3);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        localFeedforward(), ObjectStreamUtils.createObjectStream(corpus()), 
parameters);
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    refined.serialize(out);
+    final FeedforwardDependencyModel reloaded =
+        new FeedforwardDependencyModel(new 
ByteArrayInputStream(out.toByteArray()));
+
+    assertEquals(3, refined.getBeamSize());
+    assertEquals(3, reloaded.getBeamSize());
+    assertEquals(3, new FeedforwardDependencyParser(reloaded).beamSize());
+    assertEquals(BEAM_SIZE, new FeedforwardDependencyParser(reloaded, 
BEAM_SIZE).beamSize());
+  }
+
+  /**
+   * Checks that a model whose manifest records a beam size that is not a 
positive
+   * integer is rejected when it is loaded.
+   *
+   * @param beamSize The recorded beam size.
+   * @throws IOException Thrown if writing the model package fails.
+   */
+  @ParameterizedTest(name = "recorded beam size {0}")
+  @ValueSource(strings = {"abc", "0", "-2", "2147483648"})
+  void testInvalidRecordedBeamSizeIsRejected(String beamSize) throws 
IOException {
+    final byte[] tampered = withChangedEntry(MANIFEST_ENTRY_NAME,
+        content -> (new String(content, StandardCharsets.UTF_8)
+            + BeamSearch.BEAM_SIZE_PARAMETER + "=" + beamSize + "\n")
+            .getBytes(StandardCharsets.UTF_8));
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyModel(new 
ByteArrayInputStream(tampered)));
+    final InvalidFormatException cause = 
assertInstanceOf(InvalidFormatException.class,
+        e.getCause());
+    assertEquals("The manifest " + BeamSearch.BEAM_SIZE_PARAMETER
+        + " must be a positive integer: " + beamSize, cause.getMessage());
+  }
+
+  /**
+   * Serializes the shared model and changes the content of one package entry.
+   *
+   * @param entryName The name of the entry to change.
+   * @param change Maps the entry content to its new content.
+   * @return The changed model package. Never {@code null}.
+   * @throws IOException Thrown if writing the model package fails.
+   */
+  private static byte[] withChangedEntry(String entryName, 
UnaryOperator<byte[]> change)
+      throws IOException {
+    final ByteArrayOutputStream valid = new ByteArrayOutputStream();
+    model.serialize(valid);
+    final ByteArrayOutputStream changed = new ByteArrayOutputStream();
+    try (ZipInputStream in = new ZipInputStream(new 
ByteArrayInputStream(valid.toByteArray()));
+         ZipOutputStream out = new ZipOutputStream(changed)) {
+      ZipEntry entry;
+      while ((entry = in.getNextEntry()) != null) {
+        final byte[] content = in.readAllBytes();
+        out.putNextEntry(new ZipEntry(entry.getName()));
+        out.write(entryName.equals(entry.getName()) ? change.apply(content) : 
content);
+        out.closeEntry();
+      }
+    }
+    return changed.toByteArray();
+  }
+
   /**
    * Checks that non-projective samples are skipped before transition 
validation and
    * that the refined model still parses the projective sample.
@@ -530,21 +612,10 @@ public class FeedforwardDependencyParserTest {
   /** Checks that a model package with a corrupt network entry is rejected. */
   @Test
   void testCorruptNetworkEntryIsRejected() throws IOException {
-    final ByteArrayOutputStream valid = new ByteArrayOutputStream();
-    model.serialize(valid);
-    final ByteArrayOutputStream corrupt = new ByteArrayOutputStream();
-    try (ZipInputStream in = new ZipInputStream(new 
ByteArrayInputStream(valid.toByteArray()));
-         ZipOutputStream out = new ZipOutputStream(corrupt)) {
-      ZipEntry entry;
-      while ((entry = in.getNextEntry()) != null) {
-        out.putNextEntry(new ZipEntry(entry.getName()));
-        
out.write(FeedforwardDependencyModel.NETWORK_ENTRY_NAME.equals(entry.getName())
-            ? "not a network".getBytes(StandardCharsets.UTF_8) : 
in.readAllBytes());
-        out.closeEntry();
-      }
-    }
+    final byte[] corrupt = 
withChangedEntry(FeedforwardDependencyModel.NETWORK_ENTRY_NAME,
+        content -> "not a network".getBytes(StandardCharsets.UTF_8));
     assertThrows(InvalidFormatException.class, () -> new 
FeedforwardDependencyModel(
-        new ByteArrayInputStream(corrupt.toByteArray())));
+        new ByteArrayInputStream(corrupt)));
   }
 
   /**
diff --git a/opennlp-docs/src/docbkx/dependency.xml 
b/opennlp-docs/src/docbkx/dependency.xml
index 2010ca7ad..b1bb54ae2 100644
--- a/opennlp-docs/src/docbkx/dependency.xml
+++ b/opennlp-docs/src/docbkx/dependency.xml
@@ -27,7 +27,7 @@
                        arc-standard transition for each parser state: 
<code>DependencyParserME</code>
                        scores the transitions with a maximum entropy model and
                        <code>FeedforwardDependencyParser</code> with a 
feedforward network. The
-                       command-line tools cover the 
<code>DependencyParserME</code> parser only.
+                       command-line tools cover both parsers.
                </para>
        </section>
 
@@ -116,9 +116,38 @@ DependencyParser parser = new FeedforwardDependencyParser(
                </para>
                <para>
                        A beam size of one decodes greedily; a larger beam 
keeps that many transition
-                       sequences per step and scores each of them. One parser 
instance can be shared
-                       between threads.
+                       sequences per step and scores each of them. Without a 
beam size the parser uses
+                       the beam size the model was refined with, or decodes 
greedily if the model was
+                       not refined. One parser instance can be shared between 
threads.
                </para>
+
+               <section xml:id="tools.dependency.feedforward.cli">
+                       <title>Command-line tools</title>
+                       <para>
+                               As with the tools in <xref 
linkend="tools.dependency.cli"/>, the feedforward
+                               trainer and evaluator read CoNLL-U in UTF-8, 
and <code>-tagset</code> selects
+                               the POS column. The parser reads 
<code>token_TAG</code> lines and writes
+                               CoNLL-U, and the evaluator logs the same 
scores. The <code>-params</code> file holds
+                               the <code>FeedforwardDependencyTrainer</code> 
keys, for example
+                               <code>EmbeddingSize</code>, 
<code>HiddenSize</code>, <code>Iterations</code>, and
+                               <code>Dropout</code>; absent keys, or a missing 
file, take the trainer's defaults.
+                               A <code>-refineParams</code> file refines the 
trained model with beam search, with
+                               <code>BeamSize</code> (at least two) setting 
the beam. With <code>-baseModel</code>
+                               the trainer refines an existing model instead 
of training a new one; its
+                               <code>-lang</code> must match the model. For 
example:
+                       </para>
+                       <programlisting>
+<![CDATA[opennlp FeedforwardDependencyParserTrainer.conllu -lang eng -data 
train.conllu \
+    -model en-depparse-ff.bin -params ff.params -refineParams refine.params
+opennlp FeedforwardDependencyParserEvaluator.conllu -model en-depparse-ff.bin 
-data dev.conllu
+opennlp FeedforwardDependencyParser -beamSize 4 en-depparse-ff.bin < 
tagged-sentences.txt]]>
+                       </programlisting>
+                       <para>
+                               The parser and evaluator take an optional 
<code>-beamSize</code>. Without it they
+                               use the beam size a refined model records, and 
parse a model that was not refined
+                               greedily. There is no feedforward 
cross-validation tool.
+                       </para>
+               </section>
        </section>
 
        <section xml:id="tools.dependency.parsing">

Reply via email to