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">