[
https://issues.apache.org/jira/browse/OPENNLP-1991?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18126906#comment-18126906
]
ASF GitHub Bot commented on OPENNLP-1991:
-----------------------------------------
krickert commented on code in PR #1323:
URL: https://github.com/apache/opennlp/pull/1323#discussion_r4266431916
##########
opennlp-api/src/main/java/opennlp/tools/util/ParamChecks.java:
##########
@@ -172,6 +173,41 @@ public static double requireNonNegative(double value,
String name) {
return value;
}
+ /**
+ * Returns {@code value} if it is greater than zero.
+ * <p>
+ * The message of a thrown exception is {@code "<name> must be positive"}.
+ *
+ * @param value The argument to check.
+ * @param name The parameter name used in the exception message.
+ * @return {@code value}, always greater than zero.
+ * @throws IllegalArgumentException Thrown if {@code value} is zero or
negative.
+ */
+ public static int requirePositive(int value, String name) {
+ if (value <= 0) {
+ throw new IllegalArgumentException(name + POSITIVE_SUFFIX);
+ }
+ return value;
+ }
+
+ /**
+ * Returns {@code value} if it is finite and greater than zero.
+ * <p>
+ * The message of a thrown exception is {@code "<name> must be positive"}.
+ *
+ * @param value The argument to check.
+ * @param name The parameter name used in the exception message.
+ * @return {@code value}, always finite and greater than zero.
+ * @throws IllegalArgumentException Thrown if {@code value} is zero,
negative, infinite,
+ * or {@code NaN}.
+ */
+ public static double requirePositive(double value, String name) {
Review Comment:
Not intended. requirePositive now allows infinity like requireNonNegative
does. The trainer also calls requireFinite for the learning rate. Updated the
test.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardNetwork.java:
##########
@@ -0,0 +1,1011 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import opennlp.tools.ml.ArrayMath;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.model.ArtifactSerializer;
+
+/**
+ * The weights of the feedforward transition parser: embeddings for words,
tags, and arc
+ * labels, one hidden layer with cube activation, and a transition output
layer, after
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>The network holds every forward computation the parser and the trainer
share:
+ * {@link #addContribution} is the per-position hidden sum, {@link
#outputScores} the
+ * output layer, {@link #score} the complete inference pass, and
+ * {@link #logSoftmaxInPlace} the normalization of beam decoding. The trainer
writes
+ * its updates into the live arrays the accessors return; the parser never
modifies
+ * them, so a network that is not being trained can be shared between
threads.</p>
+ *
+ * <p>Each vocabulary occupies one consecutive block of embedding rows and
starts with
+ * its reserved symbols, so unknown symbols, absent positions, and the
artificial root
+ * always have a row. Words are matched after {@link #normalize(String)
normalization}.</p>
+ *
+ * @see FeedforwardDependencyModel
+ * @since 3.0.0
+ */
+final class FeedforwardNetwork {
+
+ /** The vocabulary key of every word, tag, or label the network has no
embedding row for. */
+ static final String UNKNOWN = "*UNK*";
+
+ /** The reserved word symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_WORD_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE};
+
+ /** The reserved tag symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_TAG_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE};
+
+ /** The reserved label symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_LABEL_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE};
+
+ /** The embedding rows the reserved symbols of all three vocabularies take.
*/
+ static final int RESERVED_ROWS = RESERVED_WORD_SYMBOLS.length
+ + RESERVED_TAG_SYMBOLS.length + RESERVED_LABEL_SYMBOLS.length;
+
+ /** The embedding row of the unknown symbol in each vocabulary block. */
+ private static final int UNKNOWN_OFFSET = 0;
+
+ /** The embedding row of the absent-position symbol in each vocabulary
block. */
+ private static final int NONE_OFFSET = 1;
+
+ /** The embedding row of the root symbol in the word and tag blocks. */
+ private static final int ROOT_OFFSET = 2;
+
+ private final Map<String, Integer> wordIds;
+ private final Map<String, Integer> tagIds;
+ private final Map<String, Integer> labelIds;
+
+ /** The first embedding row of each vocabulary, indexed like the
vocabularies above. */
+ private final int firstWordRow;
+ private final int firstTagRow;
+ private final int firstLabelRow;
+
+ private final String[] transitions;
+ private final Transition[] decodedTransitions;
+ private final Map<Transition, Integer> transitionIds;
+
+ private final int embeddingSize;
+ private final float[][] embeddings;
+ private final float[][] hiddenWeights;
+ private final float[] hiddenBias;
+ private final float[][] outputWeights;
+ private final float[] outputBias;
+
+ /**
+ * Assembles a network from its vocabularies and weights, copying the maps
so later
+ * changes by the caller do not reach the network.
+ *
+ * @param wordIds The word vocabulary, mapping each normalized word to its
row.
+ * @param tagIds The tag vocabulary.
+ * @param labelIds The dependency label vocabulary.
+ * @param transitions The transition inventory, indexed by output row.
+ * @param embeddingSize The embedding dimensionality.
+ * @param embeddings The embedding rows for words, tags, and labels.
+ * @param hiddenWeights The hidden layer weights.
+ * @param hiddenBias The hidden layer bias.
+ * @param outputWeights The output layer weights.
+ * @param outputBias The output layer bias.
+ * @throws IllegalArgumentException Thrown if a vocabulary lacks a reserved
symbol, the
+ * vocabularies do not partition the embedding rows into consecutive
blocks
+ * that start with the reserved symbols, a transition repeats or
does not
+ * decode, the inventory lacks SHIFT or RIGHT_ARC, or a weight
dimension does
+ * not match.
+ */
+ FeedforwardNetwork(Map<String, Integer> wordIds, Map<String, Integer> tagIds,
+ Map<String, Integer> labelIds, String[] transitions, int embeddingSize,
+ float[][] embeddings, float[][] hiddenWeights, float[] hiddenBias,
+ float[][] outputWeights, float[] outputBias) {
+ this.wordIds = Map.copyOf(wordIds);
+ this.tagIds = Map.copyOf(tagIds);
+ this.labelIds = Map.copyOf(labelIds);
+ this.transitions = transitions.clone();
+ this.embeddingSize = embeddingSize;
+ this.embeddings = embeddings;
+ this.hiddenWeights = hiddenWeights;
+ this.hiddenBias = hiddenBias;
+ this.outputWeights = outputWeights;
+ this.outputBias = outputBias;
+
+ final long rows = (long) wordIds.size() + tagIds.size() + labelIds.size();
+ if (rows != embeddings.length) {
+ throw new IllegalArgumentException("embedding matrix has " +
embeddings.length
+ + " rows for " + rows + " vocabulary entries");
+ }
+ final boolean[] taken = new boolean[embeddings.length];
+ this.firstWordRow = validateVocabulary(this.wordIds, taken, "word
vocabulary",
+ RESERVED_WORD_SYMBOLS);
+ this.firstTagRow = validateVocabulary(this.tagIds, taken, "tag vocabulary",
+ RESERVED_TAG_SYMBOLS);
+ this.firstLabelRow = validateVocabulary(this.labelIds, taken, "label
vocabulary",
+ RESERVED_LABEL_SYMBOLS);
+
+ this.decodedTransitions = new Transition[this.transitions.length];
Review Comment:
Moved to Transition.decodeInventory so both parsers share it and give the
same messages (e.g. "not a transition: JUMP").
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardNetwork.java:
##########
@@ -0,0 +1,1011 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import opennlp.tools.ml.ArrayMath;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.model.ArtifactSerializer;
+
+/**
+ * The weights of the feedforward transition parser: embeddings for words,
tags, and arc
+ * labels, one hidden layer with cube activation, and a transition output
layer, after
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>The network holds every forward computation the parser and the trainer
share:
+ * {@link #addContribution} is the per-position hidden sum, {@link
#outputScores} the
+ * output layer, {@link #score} the complete inference pass, and
+ * {@link #logSoftmaxInPlace} the normalization of beam decoding. The trainer
writes
+ * its updates into the live arrays the accessors return; the parser never
modifies
+ * them, so a network that is not being trained can be shared between
threads.</p>
+ *
+ * <p>Each vocabulary occupies one consecutive block of embedding rows and
starts with
+ * its reserved symbols, so unknown symbols, absent positions, and the
artificial root
+ * always have a row. Words are matched after {@link #normalize(String)
normalization}.</p>
+ *
+ * @see FeedforwardDependencyModel
+ * @since 3.0.0
+ */
+final class FeedforwardNetwork {
+
+ /** The vocabulary key of every word, tag, or label the network has no
embedding row for. */
+ static final String UNKNOWN = "*UNK*";
+
+ /** The reserved word symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_WORD_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE};
+
+ /** The reserved tag symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_TAG_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE};
+
+ /** The reserved label symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_LABEL_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE};
+
+ /** The embedding rows the reserved symbols of all three vocabularies take.
*/
+ static final int RESERVED_ROWS = RESERVED_WORD_SYMBOLS.length
+ + RESERVED_TAG_SYMBOLS.length + RESERVED_LABEL_SYMBOLS.length;
+
+ /** The embedding row of the unknown symbol in each vocabulary block. */
+ private static final int UNKNOWN_OFFSET = 0;
+
+ /** The embedding row of the absent-position symbol in each vocabulary
block. */
+ private static final int NONE_OFFSET = 1;
+
+ /** The embedding row of the root symbol in the word and tag blocks. */
+ private static final int ROOT_OFFSET = 2;
+
+ private final Map<String, Integer> wordIds;
+ private final Map<String, Integer> tagIds;
+ private final Map<String, Integer> labelIds;
+
+ /** The first embedding row of each vocabulary, indexed like the
vocabularies above. */
+ private final int firstWordRow;
+ private final int firstTagRow;
+ private final int firstLabelRow;
+
+ private final String[] transitions;
+ private final Transition[] decodedTransitions;
+ private final Map<Transition, Integer> transitionIds;
+
+ private final int embeddingSize;
+ private final float[][] embeddings;
+ private final float[][] hiddenWeights;
+ private final float[] hiddenBias;
+ private final float[][] outputWeights;
+ private final float[] outputBias;
+
+ /**
+ * Assembles a network from its vocabularies and weights, copying the maps
so later
+ * changes by the caller do not reach the network.
+ *
+ * @param wordIds The word vocabulary, mapping each normalized word to its
row.
+ * @param tagIds The tag vocabulary.
+ * @param labelIds The dependency label vocabulary.
+ * @param transitions The transition inventory, indexed by output row.
+ * @param embeddingSize The embedding dimensionality.
+ * @param embeddings The embedding rows for words, tags, and labels.
+ * @param hiddenWeights The hidden layer weights.
+ * @param hiddenBias The hidden layer bias.
+ * @param outputWeights The output layer weights.
+ * @param outputBias The output layer bias.
+ * @throws IllegalArgumentException Thrown if a vocabulary lacks a reserved
symbol, the
+ * vocabularies do not partition the embedding rows into consecutive
blocks
+ * that start with the reserved symbols, a transition repeats or
does not
+ * decode, the inventory lacks SHIFT or RIGHT_ARC, or a weight
dimension does
+ * not match.
+ */
+ FeedforwardNetwork(Map<String, Integer> wordIds, Map<String, Integer> tagIds,
+ Map<String, Integer> labelIds, String[] transitions, int embeddingSize,
+ float[][] embeddings, float[][] hiddenWeights, float[] hiddenBias,
+ float[][] outputWeights, float[] outputBias) {
+ this.wordIds = Map.copyOf(wordIds);
+ this.tagIds = Map.copyOf(tagIds);
+ this.labelIds = Map.copyOf(labelIds);
+ this.transitions = transitions.clone();
+ this.embeddingSize = embeddingSize;
+ this.embeddings = embeddings;
+ this.hiddenWeights = hiddenWeights;
+ this.hiddenBias = hiddenBias;
+ this.outputWeights = outputWeights;
+ this.outputBias = outputBias;
+
+ final long rows = (long) wordIds.size() + tagIds.size() + labelIds.size();
+ if (rows != embeddings.length) {
+ throw new IllegalArgumentException("embedding matrix has " +
embeddings.length
+ + " rows for " + rows + " vocabulary entries");
+ }
+ final boolean[] taken = new boolean[embeddings.length];
+ this.firstWordRow = validateVocabulary(this.wordIds, taken, "word
vocabulary",
+ RESERVED_WORD_SYMBOLS);
+ this.firstTagRow = validateVocabulary(this.tagIds, taken, "tag vocabulary",
+ RESERVED_TAG_SYMBOLS);
+ this.firstLabelRow = validateVocabulary(this.labelIds, taken, "label
vocabulary",
+ RESERVED_LABEL_SYMBOLS);
+
+ this.decodedTransitions = new Transition[this.transitions.length];
+ this.transitionIds = new HashMap<>();
+ boolean hasShift = false;
+ boolean hasRightArc = false;
+ for (int i = 0; i < this.transitions.length; i++) {
+ try {
+ decodedTransitions[i] = Transition.decode(this.transitions[i]);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("invalid transition: " +
this.transitions[i], e);
+ }
+ if (transitionIds.put(decodedTransitions[i], i) != null) {
+ throw new IllegalArgumentException("duplicate transition: " +
this.transitions[i]);
+ }
+ hasShift |= decodedTransitions[i].type() == Transition.Type.SHIFT;
+ hasRightArc |= decodedTransitions[i].type() == Transition.Type.RIGHT_ARC;
+ }
+ if (!hasShift) {
+ throw new IllegalArgumentException("transition inventory has no SHIFT
action");
+ }
+ if (!hasRightArc) {
+ throw new IllegalArgumentException("transition inventory has no
RIGHT_ARC action");
+ }
+
+ final int inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+ requireColumns(embeddings, embeddingSize, "embedding matrix");
+ requireColumns(hiddenWeights, inputSize, "hidden matrix");
+ if (hiddenBias.length != hiddenWeights.length) {
+ throw new IllegalArgumentException("hidden bias length is " +
hiddenBias.length
+ + ", expected " + hiddenWeights.length);
+ }
+ if (outputWeights.length != this.transitions.length) {
+ throw new IllegalArgumentException("output matrix row count is "
+ + outputWeights.length + ", expected " + this.transitions.length);
+ }
+ requireColumns(outputWeights, hiddenWeights.length, "output matrix");
+ if (outputBias.length != this.transitions.length) {
+ throw new IllegalArgumentException("output bias length is " +
outputBias.length
+ + ", expected " + this.transitions.length);
+ }
+ }
+
+ /**
+ * Checks that one vocabulary starts with its reserved symbols and occupies
one
+ * consecutive block of embedding rows that no other vocabulary uses.
+ *
+ * @param ids The vocabulary to check.
+ * @param taken The rows claimed so far across all vocabularies; updated in
place.
+ * @param label The vocabulary name used in error messages.
+ * @param reserved The reserved symbols, in the order of their rows.
+ * @return The first row of the block.
+ * @throws IllegalArgumentException Thrown if the block is not consecutive,
a row is
+ * already taken, or a reserved symbol is missing or misplaced.
+ */
+ private static int validateVocabulary(Map<String, Integer> ids, boolean[]
taken,
+ String label, String[] reserved) {
+ int min = Integer.MAX_VALUE;
+ int max = Integer.MIN_VALUE;
+ for (final Map.Entry<String, Integer> entry : ids.entrySet()) {
+ final int id = entry.getValue();
+ if (id < 0 || id >= taken.length) {
+ throw new IllegalArgumentException(label + " id out of range for "
+ + entry.getKey() + ": " + id);
+ }
+ if (taken[id]) {
+ throw new IllegalArgumentException("duplicate embedding id: " + id);
+ }
+ taken[id] = true;
+ min = Math.min(min, id);
+ max = Math.max(max, id);
+ }
+ if (ids.size() < reserved.length || max - min + 1 != ids.size()) {
+ throw new IllegalArgumentException(label + " must occupy one consecutive
block of "
+ + "embedding rows that starts with its reserved symbols");
+ }
+ for (int i = 0; i < reserved.length; i++) {
+ final Integer id = ids.get(reserved[i]);
+ if (id == null || id != min + i) {
+ throw new IllegalArgumentException(label + " must have " + reserved[i]
+ + " at row " + (min + i));
+ }
+ }
+ return min;
+ }
+
+ /**
+ * @param matrix The matrix to check.
+ * @param columns The required width of every row.
+ * @param label The matrix name used in error messages.
+ * @throws IllegalArgumentException Thrown if a row has another width.
+ */
+ private static void requireColumns(float[][] matrix, int columns, String
label) {
+ for (final float[] row : matrix) {
+ if (row.length != columns) {
+ throw new IllegalArgumentException(label + " column count is " +
row.length
+ + ", expected " + columns);
+ }
+ }
+ }
+
+ /**
+ * Scores every transition for a configuration described by embedding rows.
+ *
+ * @param features The embedding rows of the configuration, as produced by
+ * {@link FeedforwardContext#featureIds}. Must not be {@code
null}.
+ * @param cache The contribution cache to read, or {@code null} to compute
every
+ * contribution.
+ * @return One unnormalized score per transition, indexed like
+ * {@link #transitions()}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code features} is {@code
null}, does not
+ * have the required length, or contains an invalid embedding row.
+ * @throws IllegalStateException Thrown if the network produces a non-finite
score.
+ */
+ double[] score(int[] features, ContributionCache cache) {
+ ParamChecks.requireNonNullArg(features, "features");
+ if (features.length != FeedforwardContext.FEATURE_COUNT) {
+ throw new IllegalArgumentException("features must contain "
+ + FeedforwardContext.FEATURE_COUNT + " embedding indices");
+ }
+ for (int i = 0; i < features.length; i++) {
+ if (features[i] < 0 || features[i] >= embeddings.length) {
+ throw new IllegalArgumentException(
+ "feature embedding out of range at index " + i + ": " +
features[i]);
+ }
+ }
+ final double[] hidden = new double[hiddenBias.length];
Review Comment:
score now writes into buffers passed by the caller. Decoders allocate them
once per sentence, refinement reuses the optimizer buffers.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardNetwork.java:
##########
@@ -0,0 +1,1011 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import opennlp.tools.ml.ArrayMath;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.model.ArtifactSerializer;
+
+/**
+ * The weights of the feedforward transition parser: embeddings for words,
tags, and arc
+ * labels, one hidden layer with cube activation, and a transition output
layer, after
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>The network holds every forward computation the parser and the trainer
share:
+ * {@link #addContribution} is the per-position hidden sum, {@link
#outputScores} the
+ * output layer, {@link #score} the complete inference pass, and
+ * {@link #logSoftmaxInPlace} the normalization of beam decoding. The trainer
writes
+ * its updates into the live arrays the accessors return; the parser never
modifies
+ * them, so a network that is not being trained can be shared between
threads.</p>
+ *
+ * <p>Each vocabulary occupies one consecutive block of embedding rows and
starts with
+ * its reserved symbols, so unknown symbols, absent positions, and the
artificial root
+ * always have a row. Words are matched after {@link #normalize(String)
normalization}.</p>
+ *
+ * @see FeedforwardDependencyModel
+ * @since 3.0.0
+ */
+final class FeedforwardNetwork {
+
+ /** The vocabulary key of every word, tag, or label the network has no
embedding row for. */
+ static final String UNKNOWN = "*UNK*";
+
+ /** The reserved word symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_WORD_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE};
+
+ /** The reserved tag symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_TAG_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE};
+
+ /** The reserved label symbols, in the order of their embedding rows. */
+ static final String[] RESERVED_LABEL_SYMBOLS = {UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE};
+
+ /** The embedding rows the reserved symbols of all three vocabularies take.
*/
+ static final int RESERVED_ROWS = RESERVED_WORD_SYMBOLS.length
+ + RESERVED_TAG_SYMBOLS.length + RESERVED_LABEL_SYMBOLS.length;
+
+ /** The embedding row of the unknown symbol in each vocabulary block. */
+ private static final int UNKNOWN_OFFSET = 0;
+
+ /** The embedding row of the absent-position symbol in each vocabulary
block. */
+ private static final int NONE_OFFSET = 1;
+
+ /** The embedding row of the root symbol in the word and tag blocks. */
+ private static final int ROOT_OFFSET = 2;
+
+ private final Map<String, Integer> wordIds;
+ private final Map<String, Integer> tagIds;
+ private final Map<String, Integer> labelIds;
+
+ /** The first embedding row of each vocabulary, indexed like the
vocabularies above. */
+ private final int firstWordRow;
+ private final int firstTagRow;
+ private final int firstLabelRow;
+
+ private final String[] transitions;
+ private final Transition[] decodedTransitions;
+ private final Map<Transition, Integer> transitionIds;
+
+ private final int embeddingSize;
+ private final float[][] embeddings;
+ private final float[][] hiddenWeights;
+ private final float[] hiddenBias;
+ private final float[][] outputWeights;
+ private final float[] outputBias;
+
+ /**
+ * Assembles a network from its vocabularies and weights, copying the maps
so later
+ * changes by the caller do not reach the network.
+ *
+ * @param wordIds The word vocabulary, mapping each normalized word to its
row.
+ * @param tagIds The tag vocabulary.
+ * @param labelIds The dependency label vocabulary.
+ * @param transitions The transition inventory, indexed by output row.
+ * @param embeddingSize The embedding dimensionality.
+ * @param embeddings The embedding rows for words, tags, and labels.
+ * @param hiddenWeights The hidden layer weights.
+ * @param hiddenBias The hidden layer bias.
+ * @param outputWeights The output layer weights.
+ * @param outputBias The output layer bias.
+ * @throws IllegalArgumentException Thrown if a vocabulary lacks a reserved
symbol, the
+ * vocabularies do not partition the embedding rows into consecutive
blocks
+ * that start with the reserved symbols, a transition repeats or
does not
+ * decode, the inventory lacks SHIFT or RIGHT_ARC, or a weight
dimension does
+ * not match.
+ */
+ FeedforwardNetwork(Map<String, Integer> wordIds, Map<String, Integer> tagIds,
+ Map<String, Integer> labelIds, String[] transitions, int embeddingSize,
+ float[][] embeddings, float[][] hiddenWeights, float[] hiddenBias,
+ float[][] outputWeights, float[] outputBias) {
+ this.wordIds = Map.copyOf(wordIds);
+ this.tagIds = Map.copyOf(tagIds);
+ this.labelIds = Map.copyOf(labelIds);
+ this.transitions = transitions.clone();
+ this.embeddingSize = embeddingSize;
+ this.embeddings = embeddings;
+ this.hiddenWeights = hiddenWeights;
+ this.hiddenBias = hiddenBias;
+ this.outputWeights = outputWeights;
+ this.outputBias = outputBias;
+
+ final long rows = (long) wordIds.size() + tagIds.size() + labelIds.size();
+ if (rows != embeddings.length) {
+ throw new IllegalArgumentException("embedding matrix has " +
embeddings.length
+ + " rows for " + rows + " vocabulary entries");
+ }
+ final boolean[] taken = new boolean[embeddings.length];
+ this.firstWordRow = validateVocabulary(this.wordIds, taken, "word
vocabulary",
+ RESERVED_WORD_SYMBOLS);
+ this.firstTagRow = validateVocabulary(this.tagIds, taken, "tag vocabulary",
+ RESERVED_TAG_SYMBOLS);
+ this.firstLabelRow = validateVocabulary(this.labelIds, taken, "label
vocabulary",
+ RESERVED_LABEL_SYMBOLS);
+
+ this.decodedTransitions = new Transition[this.transitions.length];
+ this.transitionIds = new HashMap<>();
+ boolean hasShift = false;
+ boolean hasRightArc = false;
+ for (int i = 0; i < this.transitions.length; i++) {
+ try {
+ decodedTransitions[i] = Transition.decode(this.transitions[i]);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("invalid transition: " +
this.transitions[i], e);
+ }
+ if (transitionIds.put(decodedTransitions[i], i) != null) {
+ throw new IllegalArgumentException("duplicate transition: " +
this.transitions[i]);
+ }
+ hasShift |= decodedTransitions[i].type() == Transition.Type.SHIFT;
+ hasRightArc |= decodedTransitions[i].type() == Transition.Type.RIGHT_ARC;
+ }
+ if (!hasShift) {
+ throw new IllegalArgumentException("transition inventory has no SHIFT
action");
+ }
+ if (!hasRightArc) {
+ throw new IllegalArgumentException("transition inventory has no
RIGHT_ARC action");
+ }
+
+ final int inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+ requireColumns(embeddings, embeddingSize, "embedding matrix");
+ requireColumns(hiddenWeights, inputSize, "hidden matrix");
+ if (hiddenBias.length != hiddenWeights.length) {
+ throw new IllegalArgumentException("hidden bias length is " +
hiddenBias.length
+ + ", expected " + hiddenWeights.length);
+ }
+ if (outputWeights.length != this.transitions.length) {
+ throw new IllegalArgumentException("output matrix row count is "
+ + outputWeights.length + ", expected " + this.transitions.length);
+ }
+ requireColumns(outputWeights, hiddenWeights.length, "output matrix");
+ if (outputBias.length != this.transitions.length) {
+ throw new IllegalArgumentException("output bias length is " +
outputBias.length
+ + ", expected " + this.transitions.length);
+ }
+ }
+
+ /**
+ * Checks that one vocabulary starts with its reserved symbols and occupies
one
+ * consecutive block of embedding rows that no other vocabulary uses.
+ *
+ * @param ids The vocabulary to check.
+ * @param taken The rows claimed so far across all vocabularies; updated in
place.
+ * @param label The vocabulary name used in error messages.
+ * @param reserved The reserved symbols, in the order of their rows.
+ * @return The first row of the block.
+ * @throws IllegalArgumentException Thrown if the block is not consecutive,
a row is
+ * already taken, or a reserved symbol is missing or misplaced.
+ */
+ private static int validateVocabulary(Map<String, Integer> ids, boolean[]
taken,
+ String label, String[] reserved) {
+ int min = Integer.MAX_VALUE;
+ int max = Integer.MIN_VALUE;
+ for (final Map.Entry<String, Integer> entry : ids.entrySet()) {
+ final int id = entry.getValue();
+ if (id < 0 || id >= taken.length) {
+ throw new IllegalArgumentException(label + " id out of range for "
+ + entry.getKey() + ": " + id);
+ }
+ if (taken[id]) {
+ throw new IllegalArgumentException("duplicate embedding id: " + id);
+ }
+ taken[id] = true;
+ min = Math.min(min, id);
+ max = Math.max(max, id);
+ }
+ if (ids.size() < reserved.length || max - min + 1 != ids.size()) {
+ throw new IllegalArgumentException(label + " must occupy one consecutive
block of "
+ + "embedding rows that starts with its reserved symbols");
+ }
+ for (int i = 0; i < reserved.length; i++) {
+ final Integer id = ids.get(reserved[i]);
+ if (id == null || id != min + i) {
+ throw new IllegalArgumentException(label + " must have " + reserved[i]
+ + " at row " + (min + i));
+ }
+ }
+ return min;
+ }
+
+ /**
+ * @param matrix The matrix to check.
+ * @param columns The required width of every row.
+ * @param label The matrix name used in error messages.
+ * @throws IllegalArgumentException Thrown if a row has another width.
+ */
+ private static void requireColumns(float[][] matrix, int columns, String
label) {
+ for (final float[] row : matrix) {
+ if (row.length != columns) {
+ throw new IllegalArgumentException(label + " column count is " +
row.length
+ + ", expected " + columns);
+ }
+ }
+ }
+
+ /**
+ * Scores every transition for a configuration described by embedding rows.
+ *
+ * @param features The embedding rows of the configuration, as produced by
+ * {@link FeedforwardContext#featureIds}. Must not be {@code
null}.
+ * @param cache The contribution cache to read, or {@code null} to compute
every
+ * contribution.
+ * @return One unnormalized score per transition, indexed like
+ * {@link #transitions()}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code features} is {@code
null}, does not
+ * have the required length, or contains an invalid embedding row.
+ * @throws IllegalStateException Thrown if the network produces a non-finite
score.
+ */
+ double[] score(int[] features, ContributionCache cache) {
+ ParamChecks.requireNonNullArg(features, "features");
+ if (features.length != FeedforwardContext.FEATURE_COUNT) {
+ throw new IllegalArgumentException("features must contain "
+ + FeedforwardContext.FEATURE_COUNT + " embedding indices");
+ }
+ for (int i = 0; i < features.length; i++) {
+ if (features[i] < 0 || features[i] >= embeddings.length) {
+ throw new IllegalArgumentException(
+ "feature embedding out of range at index " + i + ": " +
features[i]);
+ }
+ }
+ final double[] hidden = new double[hiddenBias.length];
+ preActivations(features, cache, hidden);
+ for (int j = 0; j < hidden.length; j++) {
+ hidden[j] = hidden[j] * hidden[j] * hidden[j];
+ }
+ final double[] scores = new double[transitions.length];
+ outputScores(hidden, scores);
+ for (final double score : scores) {
+ if (!Double.isFinite(score)) {
+ throw new IllegalStateException("the model produced a non-finite
transition score");
+ }
+ }
+ return scores;
+ }
+
+ /**
+ * Computes the hidden pre-activations of a configuration: the bias plus the
+ * contribution of every feature position.
+ *
+ * @param features The embedding rows of the configuration, one per position.
+ * @param cache The contribution cache to read, or {@code null} to compute
every
+ * contribution.
+ * @param pre Receives the pre-activations; overwritten.
+ */
+ void preActivations(int[] features, ContributionCache cache, double[] pre) {
+ for (int j = 0; j < pre.length; j++) {
+ pre[j] = hiddenBias[j];
+ }
+ for (int f = 0; f < features.length; f++) {
+ final double[] cached = cache == null ? null : cache.contribution(f,
features[f]);
+ if (cached != null) {
+ for (int j = 0; j < pre.length; j++) {
+ pre[j] += cached[j];
+ }
+ } else {
+ addContribution(f, features[f], pre);
+ }
+ }
+ }
+
+ /**
+ * Adds the hidden-layer contribution of one embedding row at one feature
position.
+ *
+ * @param position The feature position.
+ * @param row The embedding row at that position.
+ * @param out The hidden vector to add to.
+ */
+ void addContribution(int position, int row, double[] out) {
+ final float[] embedding = embeddings[row];
+ final int offset = position * embeddingSize;
+ for (int j = 0; j < out.length; j++) {
+ final float[] weights = hiddenWeights[j];
+ double sum = 0.0;
+ for (int d = 0; d < embeddingSize; d++) {
+ sum += (double) weights[offset + d] * embedding[d];
+ }
+ out[j] += sum;
+ }
+ }
+
+ /**
+ * Applies the output layer to hidden activations.
+ *
+ * @param hidden The hidden activations after the cube.
+ * @param scores Receives one raw score per transition; overwritten.
+ */
+ void outputScores(double[] hidden, double[] scores) {
+ for (int o = 0; o < scores.length; o++) {
+ final float[] row = outputWeights[o];
+ double sum = outputBias[o];
+ for (int j = 0; j < hidden.length; j++) {
+ sum += row[j] * hidden[j];
+ }
+ scores[o] = sum;
+ }
+ }
+
+ /**
+ * Turns finite raw scores into log-probabilities in place. The maximum is
removed
+ * before the sum of exponentials so a large common offset does not lose the
+ * normalizer to rounding.
+ *
+ * @param scores The raw scores, overwritten with their log-softmax. Must
not be
+ * empty.
+ */
+ static void logSoftmaxInPlace(double[] scores) {
+ final double max = ArrayMath.max(scores);
+ for (int i = 0; i < scores.length; i++) {
+ scores[i] -= max;
+ }
+ final double logSum = ArrayMath.logSumOfExps(scores);
+ for (int i = 0; i < scores.length; i++) {
+ scores[i] -= logSum;
+ }
+ }
+
+ /**
+ * Resolves the tokens of a sentence to their word rows once, so that
extracting the
+ * features of every configuration only indexes.
+ *
+ * @param tokens The sentence tokens. Must not contain {@code null}.
+ * @return The word row per token. Never {@code null}.
+ */
+ int[] wordRows(String[] tokens) {
+ final int[] rows = new int[tokens.length];
+ for (int i = 0; i < tokens.length; i++) {
+ rows[i] = lookup(wordIds, normalize(tokens[i]), firstWordRow);
+ }
+ return rows;
+ }
+
+ /**
+ * Resolves the tags of a sentence to their tag rows once.
+ *
+ * @param tags The part-of-speech tags. Must not contain {@code null}.
+ * @return The tag row per tag. Never {@code null}.
+ */
+ int[] tagRows(String[] tags) {
+ final int[] rows = new int[tags.length];
+ for (int i = 0; i < tags.length; i++) {
+ rows[i] = lookup(tagIds, tags[i], firstTagRow);
+ }
+ return rows;
+ }
+
+ /**
+ * @param relation The arc label of an attached dependent, or {@code null}
for an
+ * absent position.
+ * @return The label row, falling back to the unknown row.
+ */
+ int labelRow(String relation) {
+ return lookup(labelIds, relation, firstLabelRow);
+ }
+
+ /**
+ * @return The word row standing for the artificial root.
+ */
+ int rootWordRow() {
+ return firstWordRow + ROOT_OFFSET;
+ }
+
+ /**
+ * @return The word row standing for an absent position.
+ */
+ int noneWordRow() {
+ return firstWordRow + NONE_OFFSET;
+ }
+
+ /**
+ * @return The tag row standing for the artificial root.
+ */
+ int rootTagRow() {
+ return firstTagRow + ROOT_OFFSET;
+ }
+
+ /**
+ * @return The tag row standing for an absent position.
+ */
+ int noneTagRow() {
+ return firstTagRow + NONE_OFFSET;
+ }
+
+ /**
+ * Resolves a symbol to its embedding row: absences map to the reserved
absent
+ * symbol, symbols without a row of their own to the reserved unknown symbol.
+ *
+ * @param ids The vocabulary to resolve against.
+ * @param symbol The symbol to resolve, or {@code null} for an absent
position.
+ * @param firstRow The first row of the vocabulary block.
+ * @return The embedding row of the symbol or of its fallback.
+ */
+ private static int lookup(Map<String, Integer> ids, String symbol, int
firstRow) {
+ if (symbol == null) {
+ return firstRow + NONE_OFFSET;
+ }
+ final Integer id = ids.get(symbol);
+ return id == null ? firstRow + UNKNOWN_OFFSET : id;
+ }
+
+ /**
+ * Lowercases a word symbol with {@link
StringUtil#toLowerCase(CharSequence)}, which
+ * maps per code point and applies the Greek final sigma rule; reserved
symbols and
+ * absences pass through.
+ *
+ * @param word The word to normalize. May be {@code null}.
+ * @return The vocabulary key of {@code word}, or {@code null} if {@code
word} is
+ * {@code null}.
+ */
+ static String normalize(String word) {
+ if (word == null || isReservedSymbol(word)) {
+ return word;
+ }
+ return StringUtil.toLowerCase(word);
+ }
+
+ /**
+ * @param symbol The symbol to test. May be {@code null}.
+ * @return {@code true} if {@code symbol} is one of the reserved feature
values.
+ */
+ static boolean isReservedSymbol(String symbol) {
+ // the word symbols include the tag and label symbols
+ for (final String reserved : RESERVED_WORD_SYMBOLS) {
+ if (reserved.equals(symbol)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks that a symbol fits the length the {@link Serializer} reads back.
+ *
+ * @param symbol The symbol to check.
+ * @param label The vocabulary name used in error messages.
+ * @throws IllegalArgumentException Thrown if the UTF-8 form of {@code
symbol} is
+ * longer than {@link ResourceLimits#MAX_ENTRIES} bytes.
+ */
+ static void requireStorable(String symbol, String label) {
+ // a UTF-16 unit encodes to at most three UTF-8 bytes, so short symbols
need no encoding
+ if ((long) symbol.length() * 3 <= ResourceLimits.MAX_ENTRIES) {
+ return;
+ }
+ final long bytes = symbol.getBytes(StandardCharsets.UTF_8).length;
+ if (bytes > ResourceLimits.MAX_ENTRIES) {
+ throw new IllegalArgumentException(label + " symbol length " + bytes
+ + " exceeds the limit of " + ResourceLimits.MAX_ENTRIES + " UTF-8
bytes");
+ }
+ }
+
+ /**
+ * @return The transition outcome strings by output index. Never {@code
null}.
+ */
+ String[] transitions() {
+ return transitions.clone();
+ }
+
+ /**
+ * @return The decoded transitions by output index. Never {@code null}.
+ */
+ Transition[] decodedTransitions() {
+ return decodedTransitions.clone();
+ }
+
+ /**
+ * @param transition The transition to look up.
+ * @return Its output index, or {@code -1} if the inventory lacks it.
+ */
+ int transitionIndex(Transition transition) {
+ final Integer id = transitionIds.get(transition);
+ return id == null ? -1 : id;
+ }
+
+ /**
+ * Creates an independent copy of this network: the weights are deep-copied
and the
+ * vocabularies are shared because their maps are immutable.
+ *
+ * @return A copy sharing no mutable state with this network. Never {@code
null}.
+ */
+ FeedforwardNetwork copy() {
+ return new FeedforwardNetwork(wordIds, tagIds, labelIds, transitions,
+ embeddingSize, copyOf(embeddings), copyOf(hiddenWeights),
hiddenBias.clone(),
+ copyOf(outputWeights), outputBias.clone());
+ }
+
+ /**
+ * Deep-copies a matrix, row by row.
+ *
+ * @param matrix The matrix to copy.
+ * @return A copy sharing no rows with {@code matrix}. Never {@code null}.
+ */
+ private static float[][] copyOf(float[][] matrix) {
+ final float[][] copy = new float[matrix.length][];
+ for (int r = 0; r < matrix.length; r++) {
+ copy[r] = matrix[r].clone();
+ }
+ return copy;
+ }
+
+ /**
+ * @param vocabularyRows The combined word, tag, and label row count.
+ * @param transitionCount The output layer width.
+ * @param embeddingSize The embedding dimensionality.
+ * @param hiddenSize The hidden layer width.
+ * @return The number of float values a network of that shape stores.
+ */
+ static long floatValues(long vocabularyRows, long transitionCount, long
embeddingSize,
+ long hiddenSize) {
+ final long inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+ return vocabularyRows * embeddingSize + hiddenSize * inputSize + hiddenSize
+ + transitionCount * hiddenSize + transitionCount;
+ }
+
+ /**
+ * @return The immutable map from a normalized word to its embedding row.
Never {@code null}.
+ */
+ Map<String, Integer> wordIds() {
+ return wordIds;
+ }
+
+ /**
+ * @return The immutable map from a tag to its embedding row. Never {@code
null}.
+ */
+ Map<String, Integer> tagIds() {
+ return tagIds;
+ }
+
+ /**
+ * @return The immutable map from an arc label to its embedding row. Never
{@code null}.
+ */
+ Map<String, Integer> labelIds() {
+ return labelIds;
+ }
+
+ /**
+ * @return The first embedding row of the word vocabulary block.
+ */
+ int firstWordRow() {
+ return firstWordRow;
+ }
+
+ /**
+ * @return The first embedding row of the tag vocabulary block.
+ */
+ int firstTagRow() {
+ return firstTagRow;
+ }
+
+ /**
+ * @return The first embedding row of the label vocabulary block.
+ */
+ int firstLabelRow() {
+ return firstLabelRow;
+ }
+
+ /**
+ * @return The width of one embedding row.
+ */
+ int embeddingSize() {
+ return embeddingSize;
+ }
+
+ /**
+ * @return The hidden layer width.
+ */
+ int hiddenSize() {
+ return hiddenBias.length;
+ }
+
+ /**
+ * @return The live embedding matrix, one row per vocabulary entry, not a
copy: the
+ * trainer writes its updates into it. Never {@code null}.
+ */
+ float[][] embeddings() {
+ return embeddings;
+ }
+
+ /**
+ * @return The live hidden layer weights, not a copy. Never {@code null}.
+ */
+ float[][] hiddenWeights() {
+ return hiddenWeights;
+ }
+
+ /**
+ * @return The live hidden layer bias, not a copy. Never {@code null}.
+ */
+ float[] hiddenBias() {
+ return hiddenBias;
+ }
+
+ /**
+ * @return The live output layer weights, one row per transition, not a
copy. Never
+ * {@code null}.
+ */
+ float[][] outputWeights() {
+ return outputWeights;
+ }
+
+ /**
+ * @return The live output layer bias, one entry per transition, not a copy.
Never
+ * {@code null}.
+ */
+ float[] outputBias() {
+ return outputBias;
+ }
+
+ /**
+ * Reads and writes a network as a {@link FeedforwardDependencyModel}
artifact: a
+ * four-byte magic, an {@code int} format version, the three vocabularies,
the
+ * transition inventory, the embedding size, and the weight matrices and
vectors.
+ * Symbols are stored as a byte length followed by UTF-8 bytes, so their
length is
Review Comment:
Reworded to state the limit directly. Fixed the two test comments to 70000
bytes.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java:
##########
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serial;
+import java.nio.file.Path;
+import java.util.Map;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.model.ArtifactSerializer;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * The persisted form of a trained {@link FeedforwardDependencyParser}: the
network
+ * weights plus the standard model manifest, serialized and loaded through the
same
+ * {@link BaseModel} machinery as every other tool model.
+ *
+ * <p>The weights are not modified after construction or loading. A model can
be
+ * shared between threads and between parsers; {@link
FeedforwardDependencyTrainer#refine
+ * refine} trains a copy and returns a new model.</p>
+ *
+ * @see FeedforwardDependencyParser
+ * @see FeedforwardDependencyTrainer
+ * @since 3.0.0
+ */
+public class FeedforwardDependencyModel extends BaseModel {
+
+ @Serial
+ private static final long serialVersionUID = 2746091883120417596L;
+
+ /** The component name recorded in the model manifest. */
+ private static final String COMPONENT_NAME = "FeedforwardDependencyParser";
+
+ /** The artifact map entry holding the network. */
+ static final String NETWORK_ENTRY_NAME = "feedforward.network";
+
+ /** The artifact extension the network serializer is registered under. */
+ private static final String NETWORK_EXTENSION = "network";
+
+ /**
+ * Initializes a {@link FeedforwardDependencyModel} from a trained network.
+ *
+ * @param languageCode The ISO language code of the training data. Must not
be
+ * {@code null}.
+ * @param network The trained network. Must not be {@code null}.
+ * @param manifestInfoEntries Additional entries for the manifest, or {@code
null}.
+ * @throws IllegalArgumentException Thrown if {@code languageCode} or {@code
network}
+ * is {@code null}.
+ */
+ FeedforwardDependencyModel(String languageCode, FeedforwardNetwork network,
+ Map<String, String> manifestInfoEntries) {
+ super(COMPONENT_NAME, ParamChecks.requireNonNullArg(languageCode,
"languageCode"),
+ manifestInfoEntries);
+ ParamChecks.requireNonNullArg(network, "network");
+ artifactMap.put(NETWORK_ENTRY_NAME, network);
+ checkArtifactMap();
+ }
+
+ /**
+ * Initializes a {@link FeedforwardDependencyModel} from a serialized model.
+ *
+ * @param in The stream to read the model from. Must not be {@code null}.
+ * @throws IOException Thrown if reading fails or the content is not a valid
model.
+ * @throws IllegalArgumentException Thrown if {@code in} is {@code null}.
Review Comment:
Added @throws to all three constructors for a model from another component.
##########
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java:
##########
@@ -0,0 +1,1150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+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.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+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 opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+import static opennlp.tools.depparse.DependencyTestSamples.LANGUAGE;
+import static opennlp.tools.depparse.DependencyTestSamples.MINIMAL_TRANSITIONS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TAGS;
+import static
opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TAGS;
+import static
opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.UNSEEN_RELATION;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static
opennlp.tools.depparse.DependencyTestSamples.feedforwardParameters;
+import static opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSample;
+import static
opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSampleWithUnseenRelation;
+import static opennlp.tools.depparse.DependencyTestSamples.sample;
+import static opennlp.tools.depparse.DependencyTestSamples.trainFeedforward;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests feedforward training, parsing, refinement, the model package, and the
network
+ * format.
+ */
+public class FeedforwardDependencyParserTest {
+
+ /** The beam size of the beamed parsers under test. */
+ private static final int BEAM_SIZE = 4;
+
+ /** The refinement parameters: two passes at a small step size. */
+ private static final TrainingParameters REFINE_PARAMETERS =
feedforwardParameters(2, 0.01);
+
+ /** One-pass parameters for tests that only inspect the result shape. */
+ private static final TrainingParameters SINGLE_EPOCH_PARAMETERS =
feedforwardParameters(1, 0.01);
+
+ /** The reserved rows of the hand-written networks, one per reserved symbol.
*/
+ private static final int MINIMAL_ROWS = FeedforwardNetwork.RESERVED_ROWS;
+
+ /** The first tag row of the hand-written networks, after the reserved word
rows. */
+ private static final int FIRST_TAG_ROW =
FeedforwardNetwork.RESERVED_WORD_SYMBOLS.length;
+
+ /** The first label row of the hand-written networks, after the reserved tag
rows. */
+ private static final int FIRST_LABEL_ROW =
+ FIRST_TAG_ROW + FeedforwardNetwork.RESERVED_TAG_SYMBOLS.length;
+
+ /** An embedding width that every hand-written network uses. */
+ private static final int UNIT_WIDTH = 1;
+
+ /** The hidden matrix rows that push the smallest network over the cell
limit. */
+ private static final int OVERSIZED_HIDDEN_ROWS =
Math.min(ResourceLimits.MAX_ENTRIES,
+ ResourceLimits.MAX_MATRIX_CELLS / FeedforwardContext.FEATURE_COUNT + 1);
+
+ /** A symbol longer than the 65535 bytes {@code writeUTF} could store. */
+ private static final String LONG_SYMBOL = "x".repeat(70_000);
+
+ private static FeedforwardDependencyModel model;
+ private static FeedforwardDependencyParser parser;
+
+ /**
+ * @return The distinct training sentences, one per parameterized
invocation. Never
+ * {@code null}.
+ */
+ private static List<DependencySample> trainingSamples() {
+ return DependencyTestSamples.sentences();
+ }
+
+ /**
+ * Trains the shared model once for all tests.
+ *
+ * @throws IOException Thrown if reading the in-memory samples fails.
+ */
+ @BeforeAll
+ static void trainParser() throws IOException {
+ model = DependencyTestSamples.trainFeedforward();
+ parser = new FeedforwardDependencyParser(model);
+ }
+
+ /** Checks that the greedy parser reproduces a training sentence. */
+ @Test
+ void testMemorizesTrainingSentences() {
+ assertEquals(THE_DOG_BARKS_TREE, parser.parse(THE_DOG_BARKS_TOKENS,
THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks UAS and LAS of 1.0 on the training corpus. */
+ @Test
+ void testEvaluatorScoresPerfectlyOnTrainingData() throws IOException {
+ final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+ evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+ assertEquals(1.0d, evaluator.getUas());
+ assertEquals(1.0d, evaluator.getLas());
+ }
+
+ /** Checks that unseen words still parse to a tree with one root inside the
sentence. */
+ @Test
+ void testUnknownWordsStillYieldASingleRootedTree() {
+ final DependencyTree parsed = parser.parse(new String[] {"unseen",
"words"},
+ new String[] {"JJ", "NNS"});
+ assertEquals(2, parsed.size());
+ assertTrue(parsed.root() >= 0 && parsed.root() < parsed.size(),
parsed.toString());
+ assertEquals(DependencyArc.ROOT_HEAD, parsed.headOf(parsed.root()));
+ }
+
+ /** Checks that the transition inventory is exactly the sorted set the
oracle produced. */
+ @Test
+ void testTransitionInventoryMatchesTheOracle() {
+ final TreeSet<String> expected = new TreeSet<>();
+ for (final DependencySample sample : DependencyTestSamples.sentences()) {
+ for (final Transition transition :
ArcStandardOracle.transitions(sample.getTree())) {
+ expected.add(transition.encode());
+ }
+ }
+ assertEquals(new ArrayList<>(expected),
List.of(model.network().transitions()));
+ }
+
+ /**
+ * Checks that a beam of one yields the greedy parse for every training
sample.
+ *
+ * @param sample The training sample to parse both ways.
+ */
+ @ParameterizedTest(name = "beam of one matches greedy for {0}")
+ @MethodSource("trainingSamples")
+ void testBeamOfOneMatchesGreedy(DependencySample sample) {
+ final FeedforwardDependencyParser beamed = new
FeedforwardDependencyParser(model, 1);
+ assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+ beamed.parse(sample.getTokens(), sample.getTags()));
+ }
+
+ /** Checks that beam search reproduces a training sentence. */
+ @Test
+ void testBeamedParserReproducesTrainingSentences() {
+ final FeedforwardDependencyParser beamed = new
FeedforwardDependencyParser(model, BEAM_SIZE);
+ assertEquals(THE_DOG_BARKS_TREE, beamed.parse(THE_DOG_BARKS_TOKENS,
THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks that beam search is deterministic and single-rooted on unseen
words. */
+ @Test
+ void testBeamedParseIsDeterministicAndSingleRooted() {
+ final FeedforwardDependencyParser beamed = new
FeedforwardDependencyParser(model, 8);
+ final String[] tokens = {"unseen", "words", "everywhere"};
+ final String[] tags = {"JJ", "NNS", "RB"};
+ final DependencyTree first = beamed.parse(tokens, tags);
+ assertEquals(first, beamed.parse(tokens, tags));
+ assertEquals(3, first.size());
+ assertEquals(DependencyArc.ROOT_HEAD, first.headOf(first.root()));
+ }
+
+ /** Checks that a cached parser returns the parses of an uncached one. */
+ @Test
+ void testCachedParserMatchesTheUncachedParser() {
+ final FeedforwardDependencyParser cached =
+ new FeedforwardDependencyParser(model, BEAM_SIZE, 1L << 20);
+ final FeedforwardDependencyParser uncached =
+ new FeedforwardDependencyParser(model, BEAM_SIZE);
+ for (final DependencySample sample : DependencyTestSamples.sentences()) {
+ assertEquals(uncached.parse(sample.getTokens(), sample.getTags()),
+ cached.parse(sample.getTokens(), sample.getTags()));
+ }
+ assertNotNull(cached.cache());
+ assertTrue(cached.cache().entries() > 0);
+ assertNull(uncached.cache());
+ }
+
+ /** Checks that refinement keeps perfect scores on the training corpus. */
+ @Test
+ void testRefinementKeepsToyPerformance() throws IOException {
+ final FeedforwardDependencyModel local =
+ trainFeedforward(corpus(), feedforwardParameters(60, 0.05));
+ final TrainingParameters parameters = feedforwardParameters(2, 0.01);
+ parameters.put(BeamSearch.BEAM_SIZE_PARAMETER, 2);
+ final FeedforwardDependencyModel refined =
FeedforwardDependencyTrainer.refine(
+ local, ObjectStreamUtils.createObjectStream(corpus()), parameters);
+
+ final DependencyEvaluator evaluator =
+ new DependencyEvaluator(new FeedforwardDependencyParser(refined, 2));
+ evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+ assertEquals(1.0d, evaluator.getUas());
+ assertEquals(1.0d, evaluator.getLas());
+ assertEquals("2",
refined.getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER));
+ }
+
+ /** Checks that refinement fails loudly on a relation label the model never
saw. */
+ @Test
+ void testRefineRejectsAnUnknownRelation() {
+ final List<DependencySample> unseenRelation = List.of(
+ sample(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS,
+ new int[] {1, 2, -1}, new String[] {"det", UNSEEN_RELATION,
"root"}));
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.refine(model,
+ ObjectStreamUtils.createObjectStream(unseenRelation),
REFINE_PARAMETERS));
+ assertEquals("unknown transition in the refinement samples: LEFT_ARC:" +
UNSEEN_RELATION,
+ e.getMessage());
+ }
+
+ /** Checks that refinement returns a distinct model and leaves the input
scores unchanged. */
+ @Test
+ void testRefineReturnsANewModelAndLeavesTheOriginalUntouched() throws
IOException {
+ final FeedforwardDependencyModel local =
+ trainFeedforward(corpus(), feedforwardParameters(60, 0.05));
+ final int[] features = initialFeatures(local.network(),
THE_DOG_BARKS_TOKENS,
+ THE_DOG_BARKS_TAGS);
+ final double[] before = local.network().score(features, null);
+
+ final FeedforwardDependencyModel refined =
FeedforwardDependencyTrainer.refine(
+ local, ObjectStreamUtils.createObjectStream(corpus()),
REFINE_PARAMETERS);
+
+ assertNotSame(local, refined);
+ assertArrayEquals(before, local.network().score(features, null));
+ assertFalse(Arrays.equals(before, refined.network().score(features,
null)));
+ assertEquals(LANGUAGE, refined.getLanguage());
+ }
+
+ /**
+ * Checks that non-projective samples are skipped before transition
validation and
+ * that the refined model still parses the projective sample.
+ */
+ @Test
+ void testNonProjectiveSampleWithUnknownRelationIsSkippedNotFatal() throws
IOException {
+ final List<DependencySample> mixed =
List.of(nonProjectiveSampleWithUnseenRelation(),
+ new DependencySample(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS,
THE_DOG_BARKS_TREE));
+ final FeedforwardDependencyModel refined =
FeedforwardDependencyTrainer.refine(
+ model, ObjectStreamUtils.createObjectStream(mixed),
SINGLE_EPOCH_PARAMETERS);
+ assertEquals(THE_DOG_BARKS_TREE, new FeedforwardDependencyParser(refined,
BEAM_SIZE)
+ .parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks that the inventory of a model trained on mixed data omits
non-projective labels. */
+ @Test
+ void testTrainingOmitsNonProjectiveLabels() throws IOException {
+ final List<DependencySample> mixed = new ArrayList<>(corpus());
+ mixed.add(nonProjectiveSampleWithUnseenRelation());
+ final FeedforwardDependencyModel trained = trainFeedforward(mixed,
SINGLE_EPOCH_PARAMETERS);
+ for (final String transition : trained.network().transitions()) {
+ assertFalse(transition.contains(UNSEEN_RELATION), transition);
+ }
+ }
+
+ /**
+ * Checks that training on samples without an arc-standard derivation fails
with the
+ * documented message instead of producing an empty model.
+ */
+ @Test
+ void testTrainingWithOnlyNonProjectiveSamplesFailsLoud() {
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> trainFeedforward(nonProjectiveCorpus(),
SINGLE_EPOCH_PARAMETERS));
+ assertEquals("no trainable examples in the samples", e.getMessage());
+ }
+
+ /**
+ * Checks that refining on samples without an arc-standard derivation fails
with the
+ * documented message instead of returning the model unchanged.
+ */
+ @Test
+ void testRefiningWithOnlyNonProjectiveSamplesFailsLoud() {
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.refine(model,
+ ObjectStreamUtils.createObjectStream(nonProjectiveCorpus()),
+ SINGLE_EPOCH_PARAMETERS));
+ assertEquals("no trainable samples for refinement", e.getMessage());
+ }
+
+ /**
+ * @return Two non-projective samples, so that none has an arc-standard
derivation.
+ * Never {@code null}.
+ */
+ private static List<DependencySample> nonProjectiveCorpus() {
+ return List.of(nonProjectiveSampleWithUnseenRelation(),
nonProjectiveSample());
+ }
+
+ /** Checks per-code-point lowering and reserved-symbol pass-through. */
+ @Test
+ void testNormalizeUsesTheUnicodeDataCaseMapping() {
+ // StringUtil maps per code point via UnicodeData, so no character
expands; the JDK's
+ // String.toLowerCase would render this word as "i" + COMBINING DOT ABOVE
instead.
+ assertEquals(StringUtil.toLowerCase("İSTANBUL"),
+ FeedforwardNetwork.normalize("İSTANBUL"));
+ assertEquals("istanbul", FeedforwardNetwork.normalize("İSTANBUL"));
+ assertEquals("*hello", FeedforwardNetwork.normalize("*HELLO"));
+ // supplementary code points lower too: DESERET CAPITAL LETTER LONG I
+ assertEquals("𐐨", FeedforwardNetwork.normalize("𐐀"));
+ assertEquals(FeedforwardNetwork.UNKNOWN,
FeedforwardNetwork.normalize(FeedforwardNetwork.UNKNOWN));
+ assertNull(FeedforwardNetwork.normalize(null));
+ assertEquals("barks", FeedforwardNetwork.normalize("barks"));
+ // Greek capital sigma lowers to final sigma at the end of a word
+ assertEquals("\u03BF\u03B4\u03BF\u03C2",
FeedforwardNetwork.normalize("\u039F\u0394\u039F\u03A3"));
+ }
+
+ /**
+ * Checks that embedding rows follow the sorted symbol order after the
reserved rows,
+ * so the trained weights depend only on the samples and the seed.
+ */
+ @Test
+ void testVocabularyRowsFollowSortedSymbolOrder() {
+ final FeedforwardNetwork network = model.network();
+ assertSortedAfterReserved(network.wordIds(),
FeedforwardNetwork.RESERVED_WORD_SYMBOLS);
+ assertSortedAfterReserved(network.tagIds(),
FeedforwardNetwork.RESERVED_TAG_SYMBOLS);
+ assertSortedAfterReserved(network.labelIds(),
FeedforwardNetwork.RESERVED_LABEL_SYMBOLS);
+ assertEquals(0, network.firstWordRow());
+ assertEquals(network.wordIds().size(), network.firstTagRow());
+ assertEquals(network.wordIds().size() + network.tagIds().size(),
network.firstLabelRow());
+ }
+
+ /**
+ * Asserts that the reserved symbols take the first rows of a vocabulary
block in
+ * order and the remaining symbols follow in ascending string order.
+ *
+ * @param ids The vocabulary.
+ * @param reserved The reserved symbols.
+ */
+ private static void assertSortedAfterReserved(Map<String, Integer> ids,
String[] reserved) {
+ final String[] byRow = new String[ids.size()];
+ final int first =
ids.values().stream().mapToInt(Integer::intValue).min().orElseThrow();
+ for (final Map.Entry<String, Integer> entry : ids.entrySet()) {
+ byRow[entry.getValue() - first] = entry.getKey();
+ }
+ assertArrayEquals(reserved, Arrays.copyOf(byRow, reserved.length));
+ final String[] rest = Arrays.copyOfRange(byRow, reserved.length,
byRow.length);
+ final String[] sorted = rest.clone();
+ Arrays.sort(sorted);
+ assertArrayEquals(sorted, rest);
+ }
+
+ /** Checks that the same samples and parameters give byte-identical
networks. */
+ @Test
+ void testTrainingIsDeterministic() throws IOException {
+ final byte[] first = networkBytes(trainFeedforward(corpus(),
SINGLE_EPOCH_PARAMETERS));
+ final byte[] second = networkBytes(trainFeedforward(corpus(),
SINGLE_EPOCH_PARAMETERS));
+ assertArrayEquals(first, second);
+ }
+
+ /**
+ * Pins which reserved rows training touches: the tag and label unknown rows
keep
+ * their initialization, while the word unknown row is trained once words
fall below
+ * the cutoff.
+ */
+ @Test
+ void testUnknownRowsAreTrainedOnlyForWordsBelowTheCutoff() throws
IOException {
+ final TrainingParameters slow = feedforwardParameters(1, 0.001);
+ final TrainingParameters fast = feedforwardParameters(1, 0.1);
+ final FeedforwardNetwork slowNetwork = trainFeedforward(corpus(),
slow).network();
+ final FeedforwardNetwork fastNetwork = trainFeedforward(corpus(),
fast).network();
+ assertArrayEquals(slowNetwork.embeddings()[slowNetwork.firstTagRow()],
+ fastNetwork.embeddings()[fastNetwork.firstTagRow()]);
+ assertArrayEquals(slowNetwork.embeddings()[slowNetwork.firstLabelRow()],
+ fastNetwork.embeddings()[fastNetwork.firstLabelRow()]);
+ // every word reaches the cutoff of one, so the word unknown row is
untouched too
+ assertArrayEquals(slowNetwork.embeddings()[slowNetwork.firstWordRow()],
+ fastNetwork.embeddings()[fastNetwork.firstWordRow()]);
+
+ slow.put(Parameters.CUTOFF_PARAM, Integer.MAX_VALUE);
+ fast.put(Parameters.CUTOFF_PARAM, Integer.MAX_VALUE);
+ final FeedforwardNetwork slowUnknown = trainFeedforward(corpus(),
slow).network();
+ final FeedforwardNetwork fastUnknown = trainFeedforward(corpus(),
fast).network();
+ assertEquals(FeedforwardNetwork.RESERVED_WORD_SYMBOLS.length,
slowUnknown.wordIds().size());
+
assertFalse(Arrays.equals(slowUnknown.embeddings()[slowUnknown.firstWordRow()],
+ fastUnknown.embeddings()[fastUnknown.firstWordRow()]));
+ }
+
+ /** Checks that the manifest records the component, language, and training
run. */
+ @Test
+ void testManifestRecordsTheTrainingRun() {
+ assertEquals(LANGUAGE, model.getLanguage());
+ assertEquals("120",
model.getManifestProperty(BaseModel.TRAINING_ITERATIONS_PROPERTY));
+ assertEquals("1",
model.getManifestProperty(BaseModel.TRAINING_CUTOFF_PROPERTY));
+ }
+
+ /** Checks that serialized vocabulary entries follow their numeric ids. */
+ @Test
+ void testSerializedVocabulariesAreWrittenInAscendingIdOrder() throws
IOException {
+ try (DataInputStream data = new DataInputStream(
+ new ByteArrayInputStream(networkBytes(model)))) {
+ data.readFully(new byte[FeedforwardNetwork.Serializer.MAGIC.length]);
+ assertEquals(FeedforwardNetwork.Serializer.FORMAT_VERSION,
data.readInt());
+ for (int vocabulary = 0; vocabulary < 3; vocabulary++) {
+ final int size = data.readInt();
+ int previous = Integer.MIN_VALUE;
+ for (int entry = 0; entry < size; entry++) {
+ data.readFully(new byte[data.readInt()]);
+ final int id = data.readInt();
+ assertTrue(id > previous,
+ "vocabulary " + vocabulary + " must be written in ascending id
order");
+ previous = id;
+ }
+ }
+ }
+ }
+
+ /** Checks that a serialized and reloaded model parses like the original. */
+ @Test
+ void testModelRoundTripThroughSerialization() throws IOException {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ model.serialize(out);
+ final FeedforwardDependencyModel reloaded =
+ new FeedforwardDependencyModel(new
ByteArrayInputStream(out.toByteArray()));
+ assertEquals(LANGUAGE, reloaded.getLanguage());
+ assertEquals(SHE_EATS_FISH_TREE, new FeedforwardDependencyParser(reloaded)
+ .parse(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS));
+ assertArrayEquals(networkBytes(model), networkBytes(reloaded));
+ }
+
+ /** Checks that a model file reloads and parses every corpus sentence
identically. */
+ @Test
+ void testModelFileRoundTripParsesIdentically(@TempDir Path dir) throws
IOException {
+ final Path file = dir.resolve("depparse-ff.bin");
+ model.serialize(file);
+ final FeedforwardDependencyParser reloaded =
+ new FeedforwardDependencyParser(new FeedforwardDependencyModel(file));
+ for (final DependencySample sample : corpus()) {
+ assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+ reloaded.parse(sample.getTokens(), sample.getTags()),
+ Arrays.toString(sample.getTokens()));
+ }
+ assertEquals(SHE_EATS_FISH_TREE, reloaded.parse(SHE_EATS_FISH_TOKENS,
SHE_EATS_FISH_TAGS));
+ }
+
+ /** Checks that a model file reloads through the {@link java.io.File}
constructor. */
+ @Test
+ void testModelFileConstructorParsesIdentically(@TempDir Path dir) throws
IOException {
+ final Path file = dir.resolve("depparse-ff-file.bin");
+ model.serialize(file);
+ final FeedforwardDependencyModel reloaded = new
FeedforwardDependencyModel(file.toFile());
+ assertEquals(LANGUAGE, reloaded.getLanguage());
+ assertArrayEquals(networkBytes(model), networkBytes(reloaded));
+ final FeedforwardDependencyParser reloadedParser = new
FeedforwardDependencyParser(reloaded);
+ for (final DependencySample sample : corpus()) {
+ assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+ reloadedParser.parse(sample.getTokens(), sample.getTags()),
+ Arrays.toString(sample.getTokens()));
+ }
+ }
+
+ /** 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();
+ }
+ }
+ assertThrows(InvalidFormatException.class, () -> new
FeedforwardDependencyModel(
+ new ByteArrayInputStream(corrupt.toByteArray())));
+ }
+
+ /**
+ * Checks that a model package of another component is rejected the way
+ * {@link BaseModel} rejects every foreign package.
+ */
+ @Test
+ void testForeignModelPackageIsRejected() throws IOException {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ DependencyTestSamples.train(corpus()).serialize(out);
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> new FeedforwardDependencyModel(new
ByteArrayInputStream(out.toByteArray())));
+ assertInstanceOf(InvalidFormatException.class, e.getCause());
+ }
+
+ /**
+ * @return Named invalid network payloads. Never {@code null}.
+ * @throws IOException Thrown if building a payload fails.
+ */
+ private static Stream<Arguments> invalidNetworks() throws IOException {
+ final NetworkBytes defaults = new NetworkBytes();
+ return Stream.of(
+ Arguments.of("wrong magic",
+ new
NetworkBytes().magic("ONLP".getBytes(StandardCharsets.US_ASCII)).bytes()),
+ Arguments.of("unsupported version", new
NetworkBytes().version(2).bytes()),
+ Arguments.of("negative vocabulary size", new
NetworkBytes().wordCount(-1).bytes()),
+ Arguments.of("vocabulary size above the limit",
+ new NetworkBytes().wordCount(ResourceLimits.MAX_ENTRIES +
1).bytes()),
+ Arguments.of("symbol length above the limit",
+ new NetworkBytes().firstWordLength(ResourceLimits.MAX_ENTRIES +
1).bytes()),
+ Arguments.of("truncated body", Arrays.copyOf(defaults.bytes(),
defaults.bytes().length / 2)),
+ Arguments.of("duplicate symbol", new NetworkBytes().tagSymbols(
+ FeedforwardNetwork.UNKNOWN, FeedforwardNetwork.UNKNOWN,
+ DependencyContextGenerator.ROOT_VALUE).bytes()),
+ Arguments.of("missing reserved symbol", new NetworkBytes().tagSymbols(
+ FeedforwardNetwork.UNKNOWN, DependencyContextGenerator.NONE_VALUE,
"NN").bytes()),
+ Arguments.of("overlapping vocabulary ids", new
NetworkBytes().firstTagRow(0).bytes()),
+ Arguments.of("vocabulary block with a gap", new
NetworkBytes().firstTagRow(4).bytes()),
+ Arguments.of("hidden matrix of the wrong width", new
NetworkBytes().hiddenColumns(1).bytes()),
+ Arguments.of("hidden matrix above the cell limit",
+ new NetworkBytes().hiddenRows(OVERSIZED_HIDDEN_ROWS).bytes()),
+ Arguments.of("non-finite weight", new
NetworkBytes().outputBias(Float.NaN).bytes()),
+ Arguments.of("trailing data", concat(defaults.bytes(), new byte[]
{1})),
+ Arguments.of("no SHIFT", new
NetworkBytes().transitions("RIGHT_ARC:root").bytes()),
+ Arguments.of("no RIGHT_ARC", new
NetworkBytes().transitions("SHIFT").bytes()),
+ Arguments.of("invalid transition", new
NetworkBytes().transitions("SHIFT", "JUMP").bytes()),
+ Arguments.of("duplicate transition",
+ new NetworkBytes().transitions("SHIFT", "SHIFT",
"RIGHT_ARC:root").bytes()));
+ }
+
+ /**
+ * Checks that each invalid payload is rejected with an {@link
InvalidFormatException}.
+ *
+ * @param name The defect.
+ * @param payload The network bytes.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidNetworks")
+ void testInvalidNetworkIsRejected(String name, byte[] payload) {
+ assertThrows(InvalidFormatException.class,
+ () -> new FeedforwardNetwork.Serializer().create(new
ByteArrayInputStream(payload)));
+ }
+
+ /** Checks that a well-formed minimal payload loads, with a symbol beyond
the old UTF limit. */
+ @Test
+ void testMinimalNetworkWithALongSymbolLoads() throws IOException {
+ final byte[] payload = new
NetworkBytes().tagSymbols(FeedforwardNetwork.UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE,
+ LONG_SYMBOL).bytes();
+ final FeedforwardNetwork network =
+ new FeedforwardNetwork.Serializer().create(new
ByteArrayInputStream(payload));
+ assertEquals(MINIMAL_ROWS + 1, network.embeddings().length);
+ assertEquals(FIRST_LABEL_ROW, network.tagIds().get(LONG_SYMBOL));
+ assertArrayEquals(payload, networkBytes(network));
+ }
+
+ /**
+ * Checks that a symbol of exactly {@link ResourceLimits#MAX_ENTRIES} UTF-8
bytes loads
+ * and reserializes unchanged, while one byte more is refused by the same
check the
+ * trainer applies. The limit is inclusive.
+ *
+ * @throws IOException Thrown if building the payload fails.
+ */
+ @Test
+ void testSymbolOfExactlyTheLimitLoads() throws IOException {
+ final String atLimit = "x".repeat(ResourceLimits.MAX_ENTRIES);
+ final byte[] payload = new
NetworkBytes().tagSymbols(FeedforwardNetwork.UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE,
+ atLimit).bytes();
+ final FeedforwardNetwork network =
+ new FeedforwardNetwork.Serializer().create(new
ByteArrayInputStream(payload));
+ assertEquals(FIRST_LABEL_ROW, network.tagIds().get(atLimit));
+ assertArrayEquals(payload, networkBytes(network));
+ FeedforwardNetwork.requireStorable(atLimit, "tag vocabulary");
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardNetwork.requireStorable(atLimit + "x", "tag
vocabulary"));
+ }
+
+ /**
+ * Checks the limit on the encoded length rather than the character count: a
symbol of
+ * two-byte characters that fills the limit is accepted, and one more byte
is refused.
+ * Such a symbol is too long in bytes for the length shortcut and takes the
encoding path.
+ */
+ @Test
+ void testMultiByteSymbolIsMeasuredInBytes() {
+ final String twoByte = "\u00e9".repeat(ResourceLimits.MAX_ENTRIES / 2);
+ FeedforwardNetwork.requireStorable(twoByte, "word vocabulary");
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardNetwork.requireStorable(twoByte + "x", "word
vocabulary"));
+ }
+
+ /**
+ * @return Named parameter sets that must be rejected. Never {@code null}.
+ */
+ private static Stream<Arguments> invalidParameters() {
+ return Stream.of(
+ Arguments.of("embedding size 0",
FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM, 0),
+ Arguments.of("hidden size 0",
FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM, 0),
+ Arguments.of("epochs 0", Parameters.ITERATIONS_PARAM, 0),
+ Arguments.of("batch size 0",
FeedforwardDependencyTrainer.BATCH_SIZE_PARAM, 0),
+ Arguments.of("negative learning rate",
FeedforwardDependencyTrainer.LEARNING_RATE_PARAM, -1.0),
+ Arguments.of("NaN learning rate",
FeedforwardDependencyTrainer.LEARNING_RATE_PARAM, Double.NaN),
+ Arguments.of("infinite l2", FeedforwardDependencyTrainer.L2_PARAM,
Double.POSITIVE_INFINITY),
+ Arguments.of("dropout 1", FeedforwardDependencyTrainer.DROPOUT_PARAM,
1.0),
+ Arguments.of("dropout -0.1",
FeedforwardDependencyTrainer.DROPOUT_PARAM, -0.1),
+ Arguments.of("word cutoff -1", Parameters.CUTOFF_PARAM, -1),
+ Arguments.of("embedding size above the limit",
+ FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_EMBEDDING_SIZE + 1),
+ Arguments.of("hidden size above the limit",
+ FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_HIDDEN_SIZE + 1));
+ }
+
+ /**
+ * Checks that an out-of-range hyperparameter is rejected before any sample
is read.
+ *
+ * @param name The defect.
+ * @param key The parameter key.
+ * @param value The invalid value.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidParameters")
+ void testInvalidParametersAreRejected(String name, String key, Object value)
{
+ final TrainingParameters parameters = feedforwardParameters(1, 0.01);
+ if (value instanceof Integer i) {
+ parameters.put(key, i);
+ } else {
+ parameters.put(key, (Double) value);
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.Settings.from(parameters));
+ }
+
+ /** Checks that the largest widths and the largest network together are
rejected or accepted. */
+ @Test
+ void testParameterLimitsAreInclusive() {
+ final TrainingParameters wide = feedforwardParameters(1, 0.01);
+ wide.put(FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_EMBEDDING_SIZE);
+ wide.put(FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM, 1);
+ assertEquals(FeedforwardDependencyTrainer.MAX_EMBEDDING_SIZE,
+ FeedforwardDependencyTrainer.Settings.from(wide).embeddingSize());
+
+ final TrainingParameters deep = feedforwardParameters(1, 0.01);
+ deep.put(FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM, 1);
+ deep.put(FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_HIDDEN_SIZE);
+ assertEquals(FeedforwardDependencyTrainer.MAX_HIDDEN_SIZE,
+ FeedforwardDependencyTrainer.Settings.from(deep).hiddenSize());
+
+ final TrainingParameters dropout = feedforwardParameters(1, 0.01);
+ dropout.put(FeedforwardDependencyTrainer.DROPOUT_PARAM,
Math.nextDown(1.0));
+ assertEquals(Math.nextDown(1.0),
+ FeedforwardDependencyTrainer.Settings.from(dropout).dropout());
+
+ final TrainingParameters oversized = feedforwardParameters(1, 0.01);
+ oversized.put(FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_EMBEDDING_SIZE);
+ oversized.put(FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_HIDDEN_SIZE);
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.Settings.from(oversized));
+ }
+
+ /**
+ * @return Single-epoch parameters with a beam size of one. Never {@code
null}.
+ */
+ private static TrainingParameters greedyBeamParameters() {
+ final TrainingParameters parameters = feedforwardParameters(1, 0.01);
+ parameters.put(BeamSearch.BEAM_SIZE_PARAMETER, 1);
+ return parameters;
+ }
+
+ /**
+ * Checks that the beam size only constrains refinement: local training
ignores it,
+ * so parameters shared with a greedy parser train without an error.
+ *
+ * @throws IOException Thrown if reading the in-memory samples fails.
+ */
+ @Test
+ void testTrainAcceptsAGreedyBeamSize() throws IOException {
+ final FeedforwardDependencyModel trained =
+ trainFeedforward(corpus(), greedyBeamParameters());
+ assertNotNull(new FeedforwardDependencyParser(trained)
+ .parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks that absent keys take the documented defaults. */
+ @Test
+ void testDefaultsApplyToAbsentKeys() {
+ final FeedforwardDependencyTrainer.Settings settings =
+ FeedforwardDependencyTrainer.Settings.from(new TrainingParameters());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_EMBEDDING_SIZE,
settings.embeddingSize());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_HIDDEN_SIZE,
settings.hiddenSize());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_EPOCHS,
settings.epochs());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_BATCH_SIZE,
settings.batchSize());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_LEARNING_RATE,
settings.learningRate());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_L2, settings.l2());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_DROPOUT,
settings.dropout());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_WORD_CUTOFF,
settings.wordCutoff());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_SEED, settings.seed());
+ assertEquals(FeedforwardDependencyTrainer.DEFAULT_BEAM_SIZE,
settings.beamSize());
+ }
+
+ /**
+ * Checks that training rejects a symbol the network loader could not read
back,
+ * instead of producing a model that does not load.
+ */
+ @Test
+ void testTrainingRejectsASymbolAboveTheLoaderLimit() {
+ final String oversized = "x".repeat(ResourceLimits.MAX_ENTRIES + 1);
+ final List<DependencySample> samples = List.of(sample(new String[]
{oversized, "barks"},
+ new String[] {"NN", "VBZ"}, new int[] {1, -1}, new String[] {"nsubj",
"root"}));
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> trainFeedforward(samples, SINGLE_EPOCH_PARAMETERS));
+ assertEquals("word vocabulary symbol length " +
(ResourceLimits.MAX_ENTRIES + 1L)
+ + " exceeds the limit of " + ResourceLimits.MAX_ENTRIES + " UTF-8
bytes",
+ e.getMessage());
+ }
+
+ /** Checks that every reserved symbol, and nothing else, is recognized as
reserved. */
+ @Test
+ void testReservedSymbolsAreRecognized() {
+ for (final String[] reserved :
List.of(FeedforwardNetwork.RESERVED_WORD_SYMBOLS,
+ FeedforwardNetwork.RESERVED_TAG_SYMBOLS,
FeedforwardNetwork.RESERVED_LABEL_SYMBOLS)) {
+ for (final String symbol : reserved) {
+ assertTrue(FeedforwardNetwork.isReservedSymbol(symbol), symbol);
+ }
+ }
+ assertFalse(FeedforwardNetwork.isReservedSymbol("barks"));
+ assertFalse(FeedforwardNetwork.isReservedSymbol(null));
+ }
+
+ /** Checks that a diverging run fails with an IllegalStateException. */
+ @Test
+ void testTrainingRejectsNonFiniteWeights() {
+ final TrainingParameters parameters = feedforwardParameters(2,
Double.MAX_VALUE);
+ parameters.put(FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM, 4);
+ parameters.put(FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM, 4);
+ parameters.put(FeedforwardDependencyTrainer.BATCH_SIZE_PARAM, 1);
+ assertThrows(IllegalStateException.class, () -> trainFeedforward(corpus(),
parameters));
+ }
+
+ /** Checks the feature length guards of the network. */
+ @Test
+ void testNetworkRejectsInvalidFeatureArrays() {
+ assertThrows(IllegalArgumentException.class, () ->
model.network().score(new int[0], null));
+ final int[] outOfRange = new int[FeedforwardContext.FEATURE_COUNT];
+ outOfRange[0] = model.network().embeddings().length;
+ assertThrows(IllegalArgumentException.class, () ->
model.network().score(outOfRange, null));
+ }
+
+ /** Checks that pretrained vectors seed rows and malformed vectors are
rejected. */
+ @Test
+ void testPretrainedSeedingAppliesAndValidates() throws IOException {
+ // near-zero learning keeps the seeded row observable after one epoch
+ final TrainingParameters parameters = feedforwardParameters(1, 1e-9);
+ parameters.put(FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM, 4);
+ parameters.put(FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM, 8);
+ final float[] vector = {0.25f, -0.5f, 0.75f, -1.0f};
+ final FeedforwardNetwork seeded =
FeedforwardDependencyTrainer.train(LANGUAGE,
+ ObjectStreamUtils.createObjectStream(corpus()), parameters,
+ word -> "dog".equals(word) ? vector.clone() : null).network();
+ final int row = seeded.wordIds().get("dog");
+ for (int d = 0; d < vector.length; d++) {
+ assertEquals(vector[d], seeded.embeddings()[row][d], 1e-4);
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.train(LANGUAGE,
+ ObjectStreamUtils.createObjectStream(corpus()), parameters,
+ word -> new float[] {1.0f}));
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.train(LANGUAGE,
+ ObjectStreamUtils.createObjectStream(corpus()), parameters,
+ word -> new float[] {Float.NaN, 0.0f, 0.0f, 0.0f}));
+ }
+
+ /**
+ * @return Named invalid calls with their expected messages. Never {@code
null}.
+ */
+ private static Stream<Arguments> invalidArguments() {
+ final TrainingParameters parameters = new TrainingParameters();
+ return Stream.of(
+ Arguments.of("parser without model", "model must not be null",
+ (Executable) () -> new FeedforwardDependencyParser(null)),
+ Arguments.of("parser with beam 0", "beamSize must be positive",
+ (Executable) () -> new FeedforwardDependencyParser(model, 0)),
+ Arguments.of("parser with negative cache", "scoringCacheBytes must not
be negative",
+ (Executable) () -> new FeedforwardDependencyParser(model, 1, -1L)),
+ Arguments.of("parser with a cache too small for the tables", null,
+ (Executable) () -> new FeedforwardDependencyParser(model, 1, 1L)),
+ Arguments.of("train without language", "languageCode must not be null",
+ (Executable) () -> FeedforwardDependencyTrainer.train(null,
+ ObjectStreamUtils.createObjectStream(corpus()), parameters)),
+ Arguments.of("train without samples", "samples must not be null",
+ (Executable) () -> FeedforwardDependencyTrainer.train(LANGUAGE,
null, parameters)),
+ Arguments.of("train without parameters", "parameters must not be null",
+ (Executable) () -> FeedforwardDependencyTrainer.train(LANGUAGE,
+ ObjectStreamUtils.createObjectStream(corpus()), null)),
+ Arguments.of("refine without model", "model must not be null",
+ (Executable) () -> FeedforwardDependencyTrainer.refine(null,
+ ObjectStreamUtils.createObjectStream(corpus()), parameters)),
+ Arguments.of("refine without samples", "samples must not be null",
+ (Executable) () -> FeedforwardDependencyTrainer.refine(model,
null, parameters)),
+ Arguments.of("refine without parameters", "parameters must not be
null",
+ (Executable) () -> FeedforwardDependencyTrainer.refine(model,
+ ObjectStreamUtils.createObjectStream(corpus()), null)),
+ Arguments.of("refine with beam 1", BeamSearch.BEAM_SIZE_PARAMETER
+ + " must be at least 2: 1",
+ (Executable) () -> FeedforwardDependencyTrainer.refine(model,
+ ObjectStreamUtils.createObjectStream(corpus()),
greedyBeamParameters())),
+ Arguments.of("parse without tokens", "tokens must not be null",
+ (Executable) () -> parser.parse(null, new String[] {"DT"})),
+ Arguments.of("parse without tags", "tags must not be null",
+ (Executable) () -> parser.parse(new String[] {"the"}, null)),
+ Arguments.of("parse with misaligned tags", null,
+ (Executable) () -> parser.parse(new String[] {"the"}, new String[]
{"DT", "NN"})),
+ Arguments.of("score without features", "features must not be null",
+ (Executable) () -> model.network().score(null, null)),
+ Arguments.of("model without stream", "in must not be null",
+ (Executable) () -> new FeedforwardDependencyModel((InputStream)
null)),
+ Arguments.of("model without path", "modelPath must not be null",
+ (Executable) () -> new FeedforwardDependencyModel((Path) null)));
+ }
+
+ /**
+ * Checks the argument guards of the parser, trainer, network, and model.
+ *
+ * @param name The call.
+ * @param message The expected message, or {@code null} to check only the
type.
+ * @param call The invalid call.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidArguments")
+ void testInvalidArgumentsAreRejected(String name, String message, Executable
call) {
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, call);
+ if (message != null) {
+ assertEquals(message, e.getMessage());
+ }
+ }
+
+ /**
+ * Verifies that training data using the reserved symbols as ordinary tags,
labels,
+ * or tokens does not displace the reserved rows: the model reloads and the
colliding
+ * symbols share the reserved rows the way unknown symbols do.
+ *
+ * @throws IOException Thrown if serialization or loading fails.
+ */
+ @Test
+ void testReservedSymbolsInTrainingDataSurviveReload() throws IOException {
+ final List<DependencySample> colliding = List.of(
+ sample(new String[] {"*ROOT*", "*UNK*", "barks"}, new String[]
{"*UNK*", "NN", "VBZ"},
+ new int[] {1, 2, -1}, new String[] {"*NULL*", "nsubj", "root"}),
+ sample(new String[] {"dogs", "bark"}, new String[] {"*NULL*",
"*ROOT*"},
+ new int[] {1, -1}, new String[] {"*UNK*", "root"}));
+ final FeedforwardDependencyModel trained = trainFeedforward(colliding,
SINGLE_EPOCH_PARAMETERS);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ trained.serialize(out);
+ final FeedforwardDependencyModel reloaded =
+ new FeedforwardDependencyModel(new
ByteArrayInputStream(out.toByteArray()));
+ final String[] tokens = {"*ROOT*", "*UNK*", "barks"};
+ final String[] tags = {"*UNK*", "NN", "VBZ"};
+ assertEquals(new FeedforwardDependencyParser(trained).parse(tokens, tags),
+ new FeedforwardDependencyParser(reloaded).parse(tokens, tags));
+ }
+
+ /**
+ * @param network The network whose vocabularies map the features.
+ * @param tokens The sentence tokens.
+ * @param tags The tags aligned with {@code tokens}.
+ * @return The features of the initial configuration. Never {@code null}.
+ */
+ private static int[] initialFeatures(FeedforwardNetwork network, String[]
tokens,
+ String[] tags) {
+ return new FeedforwardContext(network, tokens, tags)
+ .featureIds(new ArcStandardState(tokens.length));
+ }
+
+ /**
+ * @param model The model whose network is serialized.
+ * @return The network bytes. Never {@code null}.
+ * @throws IOException Thrown if writing fails.
+ */
+ private static byte[] networkBytes(FeedforwardDependencyModel model) throws
IOException {
+ return networkBytes(model.network());
+ }
+
+ /**
+ * @param network The network to serialize.
+ * @return The network bytes. Never {@code null}.
+ * @throws IOException Thrown if writing fails.
+ */
+ private static byte[] networkBytes(FeedforwardNetwork network) throws
IOException {
Review Comment:
Moved shared helpers to DependencyTestSamples and removed the duplicates.
The 60 epoch test model is now trained once there.
##########
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java:
##########
@@ -0,0 +1,1150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+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.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+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 opennlp.tools.ml.BeamSearch;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.ResourceLimits;
+import opennlp.tools.util.StringUtil;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.model.BaseModel;
+
+import static opennlp.tools.depparse.DependencyTestSamples.LANGUAGE;
+import static opennlp.tools.depparse.DependencyTestSamples.MINIMAL_TRANSITIONS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TAGS;
+import static
opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TAGS;
+import static
opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.UNSEEN_RELATION;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static
opennlp.tools.depparse.DependencyTestSamples.feedforwardParameters;
+import static opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSample;
+import static
opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSampleWithUnseenRelation;
+import static opennlp.tools.depparse.DependencyTestSamples.sample;
+import static opennlp.tools.depparse.DependencyTestSamples.trainFeedforward;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests feedforward training, parsing, refinement, the model package, and the
network
+ * format.
+ */
+public class FeedforwardDependencyParserTest {
+
+ /** The beam size of the beamed parsers under test. */
+ private static final int BEAM_SIZE = 4;
+
+ /** The refinement parameters: two passes at a small step size. */
+ private static final TrainingParameters REFINE_PARAMETERS =
feedforwardParameters(2, 0.01);
+
+ /** One-pass parameters for tests that only inspect the result shape. */
+ private static final TrainingParameters SINGLE_EPOCH_PARAMETERS =
feedforwardParameters(1, 0.01);
+
+ /** The reserved rows of the hand-written networks, one per reserved symbol.
*/
+ private static final int MINIMAL_ROWS = FeedforwardNetwork.RESERVED_ROWS;
+
+ /** The first tag row of the hand-written networks, after the reserved word
rows. */
+ private static final int FIRST_TAG_ROW =
FeedforwardNetwork.RESERVED_WORD_SYMBOLS.length;
+
+ /** The first label row of the hand-written networks, after the reserved tag
rows. */
+ private static final int FIRST_LABEL_ROW =
+ FIRST_TAG_ROW + FeedforwardNetwork.RESERVED_TAG_SYMBOLS.length;
+
+ /** An embedding width that every hand-written network uses. */
+ private static final int UNIT_WIDTH = 1;
+
+ /** The hidden matrix rows that push the smallest network over the cell
limit. */
+ private static final int OVERSIZED_HIDDEN_ROWS =
Math.min(ResourceLimits.MAX_ENTRIES,
+ ResourceLimits.MAX_MATRIX_CELLS / FeedforwardContext.FEATURE_COUNT + 1);
+
+ /** A symbol longer than the 65535 bytes {@code writeUTF} could store. */
+ private static final String LONG_SYMBOL = "x".repeat(70_000);
+
+ private static FeedforwardDependencyModel model;
+ private static FeedforwardDependencyParser parser;
+
+ /**
+ * @return The distinct training sentences, one per parameterized
invocation. Never
+ * {@code null}.
+ */
+ private static List<DependencySample> trainingSamples() {
+ return DependencyTestSamples.sentences();
+ }
+
+ /**
+ * Trains the shared model once for all tests.
+ *
+ * @throws IOException Thrown if reading the in-memory samples fails.
+ */
+ @BeforeAll
+ static void trainParser() throws IOException {
+ model = DependencyTestSamples.trainFeedforward();
+ parser = new FeedforwardDependencyParser(model);
+ }
+
+ /** Checks that the greedy parser reproduces a training sentence. */
+ @Test
+ void testMemorizesTrainingSentences() {
+ assertEquals(THE_DOG_BARKS_TREE, parser.parse(THE_DOG_BARKS_TOKENS,
THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks UAS and LAS of 1.0 on the training corpus. */
+ @Test
+ void testEvaluatorScoresPerfectlyOnTrainingData() throws IOException {
+ final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+ evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+ assertEquals(1.0d, evaluator.getUas());
+ assertEquals(1.0d, evaluator.getLas());
+ }
+
+ /** Checks that unseen words still parse to a tree with one root inside the
sentence. */
+ @Test
+ void testUnknownWordsStillYieldASingleRootedTree() {
+ final DependencyTree parsed = parser.parse(new String[] {"unseen",
"words"},
+ new String[] {"JJ", "NNS"});
+ assertEquals(2, parsed.size());
+ assertTrue(parsed.root() >= 0 && parsed.root() < parsed.size(),
parsed.toString());
+ assertEquals(DependencyArc.ROOT_HEAD, parsed.headOf(parsed.root()));
+ }
+
+ /** Checks that the transition inventory is exactly the sorted set the
oracle produced. */
+ @Test
+ void testTransitionInventoryMatchesTheOracle() {
+ final TreeSet<String> expected = new TreeSet<>();
+ for (final DependencySample sample : DependencyTestSamples.sentences()) {
+ for (final Transition transition :
ArcStandardOracle.transitions(sample.getTree())) {
+ expected.add(transition.encode());
+ }
+ }
+ assertEquals(new ArrayList<>(expected),
List.of(model.network().transitions()));
+ }
+
+ /**
+ * Checks that a beam of one yields the greedy parse for every training
sample.
+ *
+ * @param sample The training sample to parse both ways.
+ */
+ @ParameterizedTest(name = "beam of one matches greedy for {0}")
+ @MethodSource("trainingSamples")
+ void testBeamOfOneMatchesGreedy(DependencySample sample) {
+ final FeedforwardDependencyParser beamed = new
FeedforwardDependencyParser(model, 1);
+ assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+ beamed.parse(sample.getTokens(), sample.getTags()));
+ }
+
+ /** Checks that beam search reproduces a training sentence. */
+ @Test
+ void testBeamedParserReproducesTrainingSentences() {
+ final FeedforwardDependencyParser beamed = new
FeedforwardDependencyParser(model, BEAM_SIZE);
+ assertEquals(THE_DOG_BARKS_TREE, beamed.parse(THE_DOG_BARKS_TOKENS,
THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks that beam search is deterministic and single-rooted on unseen
words. */
+ @Test
+ void testBeamedParseIsDeterministicAndSingleRooted() {
+ final FeedforwardDependencyParser beamed = new
FeedforwardDependencyParser(model, 8);
+ final String[] tokens = {"unseen", "words", "everywhere"};
+ final String[] tags = {"JJ", "NNS", "RB"};
+ final DependencyTree first = beamed.parse(tokens, tags);
+ assertEquals(first, beamed.parse(tokens, tags));
+ assertEquals(3, first.size());
+ assertEquals(DependencyArc.ROOT_HEAD, first.headOf(first.root()));
+ }
+
+ /** Checks that a cached parser returns the parses of an uncached one. */
+ @Test
+ void testCachedParserMatchesTheUncachedParser() {
+ final FeedforwardDependencyParser cached =
+ new FeedforwardDependencyParser(model, BEAM_SIZE, 1L << 20);
+ final FeedforwardDependencyParser uncached =
+ new FeedforwardDependencyParser(model, BEAM_SIZE);
+ for (final DependencySample sample : DependencyTestSamples.sentences()) {
+ assertEquals(uncached.parse(sample.getTokens(), sample.getTags()),
+ cached.parse(sample.getTokens(), sample.getTags()));
+ }
+ assertNotNull(cached.cache());
+ assertTrue(cached.cache().entries() > 0);
+ assertNull(uncached.cache());
+ }
+
+ /** Checks that refinement keeps perfect scores on the training corpus. */
+ @Test
+ void testRefinementKeepsToyPerformance() throws IOException {
+ final FeedforwardDependencyModel local =
+ trainFeedforward(corpus(), feedforwardParameters(60, 0.05));
+ final TrainingParameters parameters = feedforwardParameters(2, 0.01);
+ parameters.put(BeamSearch.BEAM_SIZE_PARAMETER, 2);
+ final FeedforwardDependencyModel refined =
FeedforwardDependencyTrainer.refine(
+ local, ObjectStreamUtils.createObjectStream(corpus()), parameters);
+
+ final DependencyEvaluator evaluator =
+ new DependencyEvaluator(new FeedforwardDependencyParser(refined, 2));
+ evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+ assertEquals(1.0d, evaluator.getUas());
+ assertEquals(1.0d, evaluator.getLas());
+ assertEquals("2",
refined.getManifestProperty(BeamSearch.BEAM_SIZE_PARAMETER));
+ }
+
+ /** Checks that refinement fails loudly on a relation label the model never
saw. */
+ @Test
+ void testRefineRejectsAnUnknownRelation() {
+ final List<DependencySample> unseenRelation = List.of(
+ sample(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS,
+ new int[] {1, 2, -1}, new String[] {"det", UNSEEN_RELATION,
"root"}));
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.refine(model,
+ ObjectStreamUtils.createObjectStream(unseenRelation),
REFINE_PARAMETERS));
+ assertEquals("unknown transition in the refinement samples: LEFT_ARC:" +
UNSEEN_RELATION,
+ e.getMessage());
+ }
+
+ /** Checks that refinement returns a distinct model and leaves the input
scores unchanged. */
+ @Test
+ void testRefineReturnsANewModelAndLeavesTheOriginalUntouched() throws
IOException {
+ final FeedforwardDependencyModel local =
+ trainFeedforward(corpus(), feedforwardParameters(60, 0.05));
+ final int[] features = initialFeatures(local.network(),
THE_DOG_BARKS_TOKENS,
+ THE_DOG_BARKS_TAGS);
+ final double[] before = local.network().score(features, null);
+
+ final FeedforwardDependencyModel refined =
FeedforwardDependencyTrainer.refine(
+ local, ObjectStreamUtils.createObjectStream(corpus()),
REFINE_PARAMETERS);
+
+ assertNotSame(local, refined);
+ assertArrayEquals(before, local.network().score(features, null));
+ assertFalse(Arrays.equals(before, refined.network().score(features,
null)));
+ assertEquals(LANGUAGE, refined.getLanguage());
+ }
+
+ /**
+ * Checks that non-projective samples are skipped before transition
validation and
+ * that the refined model still parses the projective sample.
+ */
+ @Test
+ void testNonProjectiveSampleWithUnknownRelationIsSkippedNotFatal() throws
IOException {
+ final List<DependencySample> mixed =
List.of(nonProjectiveSampleWithUnseenRelation(),
+ new DependencySample(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS,
THE_DOG_BARKS_TREE));
+ final FeedforwardDependencyModel refined =
FeedforwardDependencyTrainer.refine(
+ model, ObjectStreamUtils.createObjectStream(mixed),
SINGLE_EPOCH_PARAMETERS);
+ assertEquals(THE_DOG_BARKS_TREE, new FeedforwardDependencyParser(refined,
BEAM_SIZE)
+ .parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+ }
+
+ /** Checks that the inventory of a model trained on mixed data omits
non-projective labels. */
+ @Test
+ void testTrainingOmitsNonProjectiveLabels() throws IOException {
+ final List<DependencySample> mixed = new ArrayList<>(corpus());
+ mixed.add(nonProjectiveSampleWithUnseenRelation());
+ final FeedforwardDependencyModel trained = trainFeedforward(mixed,
SINGLE_EPOCH_PARAMETERS);
+ for (final String transition : trained.network().transitions()) {
+ assertFalse(transition.contains(UNSEEN_RELATION), transition);
+ }
+ }
+
+ /**
+ * Checks that training on samples without an arc-standard derivation fails
with the
+ * documented message instead of producing an empty model.
+ */
+ @Test
+ void testTrainingWithOnlyNonProjectiveSamplesFailsLoud() {
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> trainFeedforward(nonProjectiveCorpus(),
SINGLE_EPOCH_PARAMETERS));
+ assertEquals("no trainable examples in the samples", e.getMessage());
+ }
+
+ /**
+ * Checks that refining on samples without an arc-standard derivation fails
with the
+ * documented message instead of returning the model unchanged.
+ */
+ @Test
+ void testRefiningWithOnlyNonProjectiveSamplesFailsLoud() {
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardDependencyTrainer.refine(model,
+ ObjectStreamUtils.createObjectStream(nonProjectiveCorpus()),
+ SINGLE_EPOCH_PARAMETERS));
+ assertEquals("no trainable samples for refinement", e.getMessage());
+ }
+
+ /**
+ * @return Two non-projective samples, so that none has an arc-standard
derivation.
+ * Never {@code null}.
+ */
+ private static List<DependencySample> nonProjectiveCorpus() {
+ return List.of(nonProjectiveSampleWithUnseenRelation(),
nonProjectiveSample());
+ }
+
+ /** Checks per-code-point lowering and reserved-symbol pass-through. */
+ @Test
+ void testNormalizeUsesTheUnicodeDataCaseMapping() {
+ // StringUtil maps per code point via UnicodeData, so no character
expands; the JDK's
+ // String.toLowerCase would render this word as "i" + COMBINING DOT ABOVE
instead.
+ assertEquals(StringUtil.toLowerCase("İSTANBUL"),
+ FeedforwardNetwork.normalize("İSTANBUL"));
+ assertEquals("istanbul", FeedforwardNetwork.normalize("İSTANBUL"));
+ assertEquals("*hello", FeedforwardNetwork.normalize("*HELLO"));
+ // supplementary code points lower too: DESERET CAPITAL LETTER LONG I
+ assertEquals("𐐨", FeedforwardNetwork.normalize("𐐀"));
+ assertEquals(FeedforwardNetwork.UNKNOWN,
FeedforwardNetwork.normalize(FeedforwardNetwork.UNKNOWN));
+ assertNull(FeedforwardNetwork.normalize(null));
+ assertEquals("barks", FeedforwardNetwork.normalize("barks"));
+ // Greek capital sigma lowers to final sigma at the end of a word
+ assertEquals("\u03BF\u03B4\u03BF\u03C2",
FeedforwardNetwork.normalize("\u039F\u0394\u039F\u03A3"));
+ }
+
+ /**
+ * Checks that embedding rows follow the sorted symbol order after the
reserved rows,
+ * so the trained weights depend only on the samples and the seed.
+ */
+ @Test
+ void testVocabularyRowsFollowSortedSymbolOrder() {
+ final FeedforwardNetwork network = model.network();
+ assertSortedAfterReserved(network.wordIds(),
FeedforwardNetwork.RESERVED_WORD_SYMBOLS);
+ assertSortedAfterReserved(network.tagIds(),
FeedforwardNetwork.RESERVED_TAG_SYMBOLS);
+ assertSortedAfterReserved(network.labelIds(),
FeedforwardNetwork.RESERVED_LABEL_SYMBOLS);
+ assertEquals(0, network.firstWordRow());
+ assertEquals(network.wordIds().size(), network.firstTagRow());
+ assertEquals(network.wordIds().size() + network.tagIds().size(),
network.firstLabelRow());
+ }
+
+ /**
+ * Asserts that the reserved symbols take the first rows of a vocabulary
block in
+ * order and the remaining symbols follow in ascending string order.
+ *
+ * @param ids The vocabulary.
+ * @param reserved The reserved symbols.
+ */
+ private static void assertSortedAfterReserved(Map<String, Integer> ids,
String[] reserved) {
+ final String[] byRow = new String[ids.size()];
+ final int first =
ids.values().stream().mapToInt(Integer::intValue).min().orElseThrow();
+ for (final Map.Entry<String, Integer> entry : ids.entrySet()) {
+ byRow[entry.getValue() - first] = entry.getKey();
+ }
+ assertArrayEquals(reserved, Arrays.copyOf(byRow, reserved.length));
+ final String[] rest = Arrays.copyOfRange(byRow, reserved.length,
byRow.length);
+ final String[] sorted = rest.clone();
+ Arrays.sort(sorted);
+ assertArrayEquals(sorted, rest);
+ }
+
+ /** Checks that the same samples and parameters give byte-identical
networks. */
+ @Test
+ void testTrainingIsDeterministic() throws IOException {
+ final byte[] first = networkBytes(trainFeedforward(corpus(),
SINGLE_EPOCH_PARAMETERS));
+ final byte[] second = networkBytes(trainFeedforward(corpus(),
SINGLE_EPOCH_PARAMETERS));
+ assertArrayEquals(first, second);
+ }
+
+ /**
+ * Pins which reserved rows training touches: the tag and label unknown rows
keep
+ * their initialization, while the word unknown row is trained once words
fall below
+ * the cutoff.
+ */
+ @Test
+ void testUnknownRowsAreTrainedOnlyForWordsBelowTheCutoff() throws
IOException {
+ final TrainingParameters slow = feedforwardParameters(1, 0.001);
+ final TrainingParameters fast = feedforwardParameters(1, 0.1);
+ final FeedforwardNetwork slowNetwork = trainFeedforward(corpus(),
slow).network();
+ final FeedforwardNetwork fastNetwork = trainFeedforward(corpus(),
fast).network();
+ assertArrayEquals(slowNetwork.embeddings()[slowNetwork.firstTagRow()],
+ fastNetwork.embeddings()[fastNetwork.firstTagRow()]);
+ assertArrayEquals(slowNetwork.embeddings()[slowNetwork.firstLabelRow()],
+ fastNetwork.embeddings()[fastNetwork.firstLabelRow()]);
+ // every word reaches the cutoff of one, so the word unknown row is
untouched too
+ assertArrayEquals(slowNetwork.embeddings()[slowNetwork.firstWordRow()],
+ fastNetwork.embeddings()[fastNetwork.firstWordRow()]);
+
+ slow.put(Parameters.CUTOFF_PARAM, Integer.MAX_VALUE);
+ fast.put(Parameters.CUTOFF_PARAM, Integer.MAX_VALUE);
+ final FeedforwardNetwork slowUnknown = trainFeedforward(corpus(),
slow).network();
+ final FeedforwardNetwork fastUnknown = trainFeedforward(corpus(),
fast).network();
+ assertEquals(FeedforwardNetwork.RESERVED_WORD_SYMBOLS.length,
slowUnknown.wordIds().size());
+
assertFalse(Arrays.equals(slowUnknown.embeddings()[slowUnknown.firstWordRow()],
+ fastUnknown.embeddings()[fastUnknown.firstWordRow()]));
+ }
+
+ /** Checks that the manifest records the component, language, and training
run. */
+ @Test
+ void testManifestRecordsTheTrainingRun() {
+ assertEquals(LANGUAGE, model.getLanguage());
+ assertEquals("120",
model.getManifestProperty(BaseModel.TRAINING_ITERATIONS_PROPERTY));
+ assertEquals("1",
model.getManifestProperty(BaseModel.TRAINING_CUTOFF_PROPERTY));
+ }
+
+ /** Checks that serialized vocabulary entries follow their numeric ids. */
+ @Test
+ void testSerializedVocabulariesAreWrittenInAscendingIdOrder() throws
IOException {
+ try (DataInputStream data = new DataInputStream(
+ new ByteArrayInputStream(networkBytes(model)))) {
+ data.readFully(new byte[FeedforwardNetwork.Serializer.MAGIC.length]);
+ assertEquals(FeedforwardNetwork.Serializer.FORMAT_VERSION,
data.readInt());
+ for (int vocabulary = 0; vocabulary < 3; vocabulary++) {
+ final int size = data.readInt();
+ int previous = Integer.MIN_VALUE;
+ for (int entry = 0; entry < size; entry++) {
+ data.readFully(new byte[data.readInt()]);
+ final int id = data.readInt();
+ assertTrue(id > previous,
+ "vocabulary " + vocabulary + " must be written in ascending id
order");
+ previous = id;
+ }
+ }
+ }
+ }
+
+ /** Checks that a serialized and reloaded model parses like the original. */
+ @Test
+ void testModelRoundTripThroughSerialization() throws IOException {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ model.serialize(out);
+ final FeedforwardDependencyModel reloaded =
+ new FeedforwardDependencyModel(new
ByteArrayInputStream(out.toByteArray()));
+ assertEquals(LANGUAGE, reloaded.getLanguage());
+ assertEquals(SHE_EATS_FISH_TREE, new FeedforwardDependencyParser(reloaded)
+ .parse(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS));
+ assertArrayEquals(networkBytes(model), networkBytes(reloaded));
+ }
+
+ /** Checks that a model file reloads and parses every corpus sentence
identically. */
+ @Test
+ void testModelFileRoundTripParsesIdentically(@TempDir Path dir) throws
IOException {
+ final Path file = dir.resolve("depparse-ff.bin");
+ model.serialize(file);
+ final FeedforwardDependencyParser reloaded =
+ new FeedforwardDependencyParser(new FeedforwardDependencyModel(file));
+ for (final DependencySample sample : corpus()) {
+ assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+ reloaded.parse(sample.getTokens(), sample.getTags()),
+ Arrays.toString(sample.getTokens()));
+ }
+ assertEquals(SHE_EATS_FISH_TREE, reloaded.parse(SHE_EATS_FISH_TOKENS,
SHE_EATS_FISH_TAGS));
+ }
+
+ /** Checks that a model file reloads through the {@link java.io.File}
constructor. */
+ @Test
+ void testModelFileConstructorParsesIdentically(@TempDir Path dir) throws
IOException {
+ final Path file = dir.resolve("depparse-ff-file.bin");
+ model.serialize(file);
+ final FeedforwardDependencyModel reloaded = new
FeedforwardDependencyModel(file.toFile());
+ assertEquals(LANGUAGE, reloaded.getLanguage());
+ assertArrayEquals(networkBytes(model), networkBytes(reloaded));
+ final FeedforwardDependencyParser reloadedParser = new
FeedforwardDependencyParser(reloaded);
+ for (final DependencySample sample : corpus()) {
+ assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+ reloadedParser.parse(sample.getTokens(), sample.getTags()),
+ Arrays.toString(sample.getTokens()));
+ }
+ }
+
+ /** 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();
+ }
+ }
+ assertThrows(InvalidFormatException.class, () -> new
FeedforwardDependencyModel(
+ new ByteArrayInputStream(corrupt.toByteArray())));
+ }
+
+ /**
+ * Checks that a model package of another component is rejected the way
+ * {@link BaseModel} rejects every foreign package.
+ */
+ @Test
+ void testForeignModelPackageIsRejected() throws IOException {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ DependencyTestSamples.train(corpus()).serialize(out);
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> new FeedforwardDependencyModel(new
ByteArrayInputStream(out.toByteArray())));
+ assertInstanceOf(InvalidFormatException.class, e.getCause());
+ }
+
+ /**
+ * @return Named invalid network payloads. Never {@code null}.
+ * @throws IOException Thrown if building a payload fails.
+ */
+ private static Stream<Arguments> invalidNetworks() throws IOException {
+ final NetworkBytes defaults = new NetworkBytes();
+ return Stream.of(
+ Arguments.of("wrong magic",
+ new
NetworkBytes().magic("ONLP".getBytes(StandardCharsets.US_ASCII)).bytes()),
+ Arguments.of("unsupported version", new
NetworkBytes().version(2).bytes()),
+ Arguments.of("negative vocabulary size", new
NetworkBytes().wordCount(-1).bytes()),
+ Arguments.of("vocabulary size above the limit",
+ new NetworkBytes().wordCount(ResourceLimits.MAX_ENTRIES +
1).bytes()),
+ Arguments.of("symbol length above the limit",
+ new NetworkBytes().firstWordLength(ResourceLimits.MAX_ENTRIES +
1).bytes()),
+ Arguments.of("truncated body", Arrays.copyOf(defaults.bytes(),
defaults.bytes().length / 2)),
+ Arguments.of("duplicate symbol", new NetworkBytes().tagSymbols(
+ FeedforwardNetwork.UNKNOWN, FeedforwardNetwork.UNKNOWN,
+ DependencyContextGenerator.ROOT_VALUE).bytes()),
+ Arguments.of("missing reserved symbol", new NetworkBytes().tagSymbols(
+ FeedforwardNetwork.UNKNOWN, DependencyContextGenerator.NONE_VALUE,
"NN").bytes()),
+ Arguments.of("overlapping vocabulary ids", new
NetworkBytes().firstTagRow(0).bytes()),
+ Arguments.of("vocabulary block with a gap", new
NetworkBytes().firstTagRow(4).bytes()),
+ Arguments.of("hidden matrix of the wrong width", new
NetworkBytes().hiddenColumns(1).bytes()),
+ Arguments.of("hidden matrix above the cell limit",
+ new NetworkBytes().hiddenRows(OVERSIZED_HIDDEN_ROWS).bytes()),
+ Arguments.of("non-finite weight", new
NetworkBytes().outputBias(Float.NaN).bytes()),
+ Arguments.of("trailing data", concat(defaults.bytes(), new byte[]
{1})),
+ Arguments.of("no SHIFT", new
NetworkBytes().transitions("RIGHT_ARC:root").bytes()),
+ Arguments.of("no RIGHT_ARC", new
NetworkBytes().transitions("SHIFT").bytes()),
+ Arguments.of("invalid transition", new
NetworkBytes().transitions("SHIFT", "JUMP").bytes()),
+ Arguments.of("duplicate transition",
+ new NetworkBytes().transitions("SHIFT", "SHIFT",
"RIGHT_ARC:root").bytes()));
+ }
+
+ /**
+ * Checks that each invalid payload is rejected with an {@link
InvalidFormatException}.
+ *
+ * @param name The defect.
+ * @param payload The network bytes.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidNetworks")
+ void testInvalidNetworkIsRejected(String name, byte[] payload) {
+ assertThrows(InvalidFormatException.class,
+ () -> new FeedforwardNetwork.Serializer().create(new
ByteArrayInputStream(payload)));
+ }
+
+ /** Checks that a well-formed minimal payload loads, with a symbol beyond
the old UTF limit. */
+ @Test
+ void testMinimalNetworkWithALongSymbolLoads() throws IOException {
+ final byte[] payload = new
NetworkBytes().tagSymbols(FeedforwardNetwork.UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE,
+ LONG_SYMBOL).bytes();
+ final FeedforwardNetwork network =
+ new FeedforwardNetwork.Serializer().create(new
ByteArrayInputStream(payload));
+ assertEquals(MINIMAL_ROWS + 1, network.embeddings().length);
+ assertEquals(FIRST_LABEL_ROW, network.tagIds().get(LONG_SYMBOL));
+ assertArrayEquals(payload, networkBytes(network));
+ }
+
+ /**
+ * Checks that a symbol of exactly {@link ResourceLimits#MAX_ENTRIES} UTF-8
bytes loads
+ * and reserializes unchanged, while one byte more is refused by the same
check the
+ * trainer applies. The limit is inclusive.
+ *
+ * @throws IOException Thrown if building the payload fails.
+ */
+ @Test
+ void testSymbolOfExactlyTheLimitLoads() throws IOException {
+ final String atLimit = "x".repeat(ResourceLimits.MAX_ENTRIES);
+ final byte[] payload = new
NetworkBytes().tagSymbols(FeedforwardNetwork.UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE,
+ atLimit).bytes();
+ final FeedforwardNetwork network =
+ new FeedforwardNetwork.Serializer().create(new
ByteArrayInputStream(payload));
+ assertEquals(FIRST_LABEL_ROW, network.tagIds().get(atLimit));
+ assertArrayEquals(payload, networkBytes(network));
+ FeedforwardNetwork.requireStorable(atLimit, "tag vocabulary");
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardNetwork.requireStorable(atLimit + "x", "tag
vocabulary"));
+ }
+
+ /**
+ * Checks the limit on the encoded length rather than the character count: a
symbol of
+ * two-byte characters that fills the limit is accepted, and one more byte
is refused.
+ * Such a symbol is too long in bytes for the length shortcut and takes the
encoding path.
+ */
+ @Test
+ void testMultiByteSymbolIsMeasuredInBytes() {
+ final String twoByte = "\u00e9".repeat(ResourceLimits.MAX_ENTRIES / 2);
+ FeedforwardNetwork.requireStorable(twoByte, "word vocabulary");
+ assertThrows(IllegalArgumentException.class,
+ () -> FeedforwardNetwork.requireStorable(twoByte + "x", "word
vocabulary"));
+ }
+
+ /**
+ * @return Named parameter sets that must be rejected. Never {@code null}.
+ */
+ private static Stream<Arguments> invalidParameters() {
+ return Stream.of(
+ Arguments.of("embedding size 0",
FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM, 0),
+ Arguments.of("hidden size 0",
FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM, 0),
+ Arguments.of("epochs 0", Parameters.ITERATIONS_PARAM, 0),
+ Arguments.of("batch size 0",
FeedforwardDependencyTrainer.BATCH_SIZE_PARAM, 0),
+ Arguments.of("negative learning rate",
FeedforwardDependencyTrainer.LEARNING_RATE_PARAM, -1.0),
+ Arguments.of("NaN learning rate",
FeedforwardDependencyTrainer.LEARNING_RATE_PARAM, Double.NaN),
+ Arguments.of("infinite l2", FeedforwardDependencyTrainer.L2_PARAM,
Double.POSITIVE_INFINITY),
+ Arguments.of("dropout 1", FeedforwardDependencyTrainer.DROPOUT_PARAM,
1.0),
+ Arguments.of("dropout -0.1",
FeedforwardDependencyTrainer.DROPOUT_PARAM, -0.1),
+ Arguments.of("word cutoff -1", Parameters.CUTOFF_PARAM, -1),
+ Arguments.of("embedding size above the limit",
+ FeedforwardDependencyTrainer.EMBEDDING_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_EMBEDDING_SIZE + 1),
+ Arguments.of("hidden size above the limit",
+ FeedforwardDependencyTrainer.HIDDEN_SIZE_PARAM,
+ FeedforwardDependencyTrainer.MAX_HIDDEN_SIZE + 1));
+ }
+
+ /**
+ * Checks that an out-of-range hyperparameter is rejected before any sample
is read.
+ *
+ * @param name The defect.
+ * @param key The parameter key.
+ * @param value The invalid value.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidParameters")
+ void testInvalidParametersAreRejected(String name, String key, Object value)
{
+ final TrainingParameters parameters = feedforwardParameters(1, 0.01);
+ if (value instanceof Integer i) {
+ parameters.put(key, i);
+ } else {
+ parameters.put(key, (Double) value);
+ }
+ assertThrows(IllegalArgumentException.class,
Review Comment:
Each row in invalidParameters and invalidNetworks now checks its message.
Overlap and gap rows use a single entry since map order is not fixed.
> Add a feedforward neural dependency parser
> ------------------------------------------
>
> Key: OPENNLP-1991
> URL: https://issues.apache.org/jira/browse/OPENNLP-1991
> Project: OpenNLP
> Issue Type: Improvement
> Reporter: Kristian Rickert
> Assignee: Kristian Rickert
> Priority: Major
> Time Spent: 10m
> Remaining Estimate: 0h
>
> Add a feedforward neural implementation of the dependency parser API
> introduced by OPENNLP-547. Use OpenNLP’s standard model container, artifact
> serializers, TrainingParameters, factory, and CLI conventions. Include
> deterministic training, greedy and beam decoding, model round-trip tests, and
> held-out Universal Dependencies evaluation. Address the deferred review
> feedback on shared scoring/training logic and parser-owned caches. Implement
> this without new dependencies.
> The list above may still get this ticket to further split. For now this is a
> placeholder to capture the code that was moved out of OPENNLP-547. I'll edit
> after 547 is merged.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)