[
https://issues.apache.org/jira/browse/OPENNLP-1991?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18126307#comment-18126307
]
ASF GitHub Bot commented on OPENNLP-1991:
-----------------------------------------
krickert commented on code in PR #1323:
URL: https://github.com/apache/opennlp/pull/1323#discussion_r4247892306
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java:
##########
@@ -0,0 +1,942 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReferenceArray;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.StringUtil;
+
+/**
+ * The weights of the feedforward transition parser: embeddings for words,
tags, and arc
+ * labels, one hidden layer with cube activation, and a transition output
layer, stored
+ * in a versioned binary format. The architecture follows
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>The same class scores configurations during training and decoding.
Unknown words
+ * use a learned fallback row; words are matched case-insensitively after
+ * {@link #normalize(String) normalization}.</p>
+ *
+ * <p>Instances returned by {@link FeedforwardDependencyTrainer} are immutable
and safe
Review Comment:
Reworded to the actual contract: the weights are not modified after
construction or loading and a model can be shared between threads and parsers.
The parser no longer touches the model; the optional cache lives in the parser.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: oracle-derived transition
examples,
+ * minibatch AdaGrad over a softmax cross-entropy loss, cube activation, and
inverted
+ * dropout on the hidden layer, the training recipe of
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>Words below the frequency cutoff share a learned unknown embedding;
absent
+ * template positions share a learned padding embedding. Non-projective
samples have no
+ * arc-standard derivation and are skipped. Training is deterministic for a
fixed
+ * {@link Settings#seed()}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+ /** The AdaGrad denominator offset that keeps the first steps finite. */
+ private static final double ADAGRAD_EPSILON = 1e-6;
+
+ /** The derivative factor of the cube activation, {@code d/dx x^3 = 3x^2}. */
+ private static final double CUBE_DERIVATIVE_FACTOR = 3.0;
+
+ /** The numerator of the Glorot uniform bound, {@code sqrt(6 / (fanIn +
fanOut))}. */
+ private static final double GLOROT_NUMERATOR = 6.0;
+
+ /** The half-width of the uniform range embeddings are drawn from. */
+ private static final double EMBEDDING_INIT_SCALE = 0.01;
+
+ /** The smallest probability the reported training loss takes the log of. */
+ private static final double LOSS_PROBABILITY_FLOOR = 1e-12;
+
+ /** The sentence loss reported when refinement made no update for a
sentence. */
+ private static final double NO_UPDATE = -1.0;
+
+ /** Special-symbol rows present before any training vocabulary is added. */
+ private static final int MIN_VOCABULARY_ROWS = 8;
+
+ /** SHIFT and at least one RIGHT_ARC are required to parse a sentence. */
+ private static final int MIN_TRANSITIONS = 2;
+
+ /** Prevents construction of this utility class. */
+ private FeedforwardDependencyTrainer() {
+ }
+
+ /**
+ * The training hyperparameters.
+ *
+ * @param embeddingSize The embedding dimensionality. Must be positive.
+ * @param hiddenSize The hidden layer width. Must be positive.
+ * @param epochs The number of passes over the examples. Must be positive.
+ * @param batchSize The minibatch size. Must be positive.
+ * @param learningRate The AdaGrad step size. Must be positive.
+ * @param l2 The L2 penalty applied to the dense weights. Must not be
negative.
+ * @param dropout The hidden dropout probability. Must be in {@code [0, 1)}.
+ * @param wordCutoff The minimum frequency for a word to get its own
embedding. Must
+ * not be negative.
+ * @param seed The random seed making a run reproducible.
+ */
+ public record Settings(int embeddingSize, int hiddenSize, int epochs, int
batchSize,
+ double learningRate, double l2, double dropout, int wordCutoff, long
seed) {
+
+ /** The default embedding dimensionality. */
+ private static final int DEFAULT_EMBEDDING_SIZE = 50;
+
+ /** The default hidden layer width. */
+ private static final int DEFAULT_HIDDEN_SIZE = 200;
+
+ /** The default number of epochs. */
+ private static final int DEFAULT_EPOCHS = 10;
+
+ /** The default minibatch size. */
+ private static final int DEFAULT_BATCH_SIZE = 256;
+
+ /** The default AdaGrad step size. */
+ private static final double DEFAULT_LEARNING_RATE = 0.02;
+
+ /** The default L2 penalty. */
+ private static final double DEFAULT_L2 = 1e-8;
+
+ /** The default hidden dropout probability. */
+ private static final double DEFAULT_DROPOUT = 0.5;
+
+ /** The default minimum word frequency for an embedding row. */
+ private static final int DEFAULT_WORD_CUTOFF = 2;
+
+ /** The default random seed. */
+ static final long DEFAULT_SEED = 17L;
+
+ /**
+ * Validates the hyperparameters.
+ *
+ * @throws IllegalArgumentException Thrown if a value is outside its
documented
+ * range.
+ */
+ public Settings {
+ requirePositive(embeddingSize, "embeddingSize");
+ requirePositive(hiddenSize, "hiddenSize");
+ requirePositive(epochs, "epochs");
+ requirePositive(batchSize, "batchSize");
+ if (embeddingSize > FeedforwardDependencyModel.MAX_EMBEDDING_SIZE) {
+ throw new IllegalArgumentException("embeddingSize exceeds the model
format limit: "
+ + embeddingSize);
+ }
+ if (hiddenSize > FeedforwardDependencyModel.MAX_HIDDEN_SIZE) {
+ throw new IllegalArgumentException("hiddenSize exceeds the model
format limit: "
+ + hiddenSize);
+ }
+ final long minimumModelValues = modelFloatValues(MIN_VOCABULARY_ROWS,
+ MIN_TRANSITIONS, embeddingSize, hiddenSize);
+ if (minimumModelValues >
FeedforwardDependencyModel.MAX_MODEL_FLOAT_VALUES) {
+ throw new IllegalArgumentException("embeddingSize and hiddenSize
exceed the model "
+ + "format allocation limit");
+ }
+ if (ParamChecks.requireFinite(learningRate, "learningRate") <= 0.0) {
+ throw new IllegalArgumentException("learningRate must be positive: " +
learningRate);
+ }
+ ParamChecks.requireNonNegative(ParamChecks.requireFinite(l2, "l2"),
"l2");
+ if (!(dropout >= 0.0 && dropout < 1.0)) {
+ throw new IllegalArgumentException("dropout must be in [0, 1): " +
dropout);
+ }
+ ParamChecks.requireNonNegative(wordCutoff, "wordCutoff");
+ }
+
+ /**
+ * Rejects a hyperparameter that is not positive.
+ *
+ * @param value The value to check.
+ * @param name The hyperparameter name used in the exception message.
+ * @throws IllegalArgumentException Thrown if {@code value} is not
positive.
+ */
+ private static void requirePositive(int value, String name) {
+ if (value <= 0) {
+ throw new IllegalArgumentException(name + " must be positive: " +
value);
+ }
+ }
+
+ /**
+ * @return The default hyperparameters. Never {@code null}.
+ */
+ public static Settings defaults() {
+ return new Settings(DEFAULT_EMBEDDING_SIZE, DEFAULT_HIDDEN_SIZE,
DEFAULT_EPOCHS,
+ DEFAULT_BATCH_SIZE, DEFAULT_LEARNING_RATE, DEFAULT_L2,
DEFAULT_DROPOUT,
+ DEFAULT_WORD_CUTOFF, DEFAULT_SEED);
+ }
+ }
+
+ /**
+ * Trains a model from dependency samples.
+ *
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters. Must not be {@code null}.
+ * @return A trained {@link FeedforwardDependencyModel}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if a parameter is {@code null} or
no
+ * trainable example can be derived from the samples.
+ */
+ public static FeedforwardDependencyModel
train(ObjectStream<DependencySample> samples,
+ Settings settings) throws IOException {
+ return train(samples, settings, null);
+ }
+
+ /**
+ * Trains a model from dependency samples, seeding word embeddings from a
pretrained
+ * source.
+ *
+ * <p>The provider is consulted once per vocabulary word during
initialization; words
+ * it returns {@code null} for keep their random initialization, and all
embeddings
+ * remain trainable afterwards. The pretrained source is a training-time
ingredient
+ * only: the learned embeddings ship inside the model, so parsing carries no
+ * dependency on the source.</p>
+ *
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters. Must not be {@code null}.
+ * @param pretrained Maps a normalized word to its pretrained vector of
exactly
+ * {@link Settings#embeddingSize()} dimensions, or {@code
null} for
+ * unknown words. May be {@code null} to disable seeding.
+ * @return A trained {@link FeedforwardDependencyModel}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if {@code samples} or {@code
settings} is
+ * {@code null}, no trainable example can be derived, or a
pretrained vector
+ * has the wrong dimensionality.
+ */
+ public static FeedforwardDependencyModel
train(ObjectStream<DependencySample> samples,
+ Settings settings, Function<String, float[]> pretrained)
+ throws IOException {
+ ParamChecks.requireNonNullArg(samples, "samples");
+ ParamChecks.requireNonNullArg(settings, "settings");
+ final List<DependencySample> corpus = readAll(samples);
+ final FeedforwardDependencyModel model = initialize(corpus, settings);
+ if (pretrained != null) {
+ seed(model, pretrained, settings);
+ }
+ final List<int[]> featureList = new ArrayList<>();
+ final List<Integer> goldList = new ArrayList<>();
+ collectExamples(corpus, model, featureList, goldList);
+ if (featureList.isEmpty()) {
+ throw new IllegalArgumentException("no trainable examples in the
samples");
+ }
+ optimize(model, featureList, goldList, settings);
+ return model;
+ }
+
+ /**
+ * Refines a locally trained model with beam search and the early-update
method of
+ * <a href="https://aclanthology.org/P04-1015/">Collins and Roark
(2004)</a>. The loss
+ * is conditional likelihood over candidate paths scored by summed
log-probabilities.
+ *
+ * <p>Refinement updates a copy with per-sentence AdaGrad steps and no
dropout.
+ * {@link Settings#epochs()} sets the number of refinement passes. Use the
same beam
+ * size for parsing the result.</p>
+ *
+ * <p>The transition inventory comes from {@code model} and is not extended,
because
+ * its size is the width of the trained output layer. A refinement corpus
using a
+ * relation label the original training set lacked is therefore rejected
rather than
+ * silently ignored.</p>
+ *
+ * @param model The locally trained model to refine. Left untouched. Must
not be
+ * {@code null}.
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters; {@code epochs}, {@code learningRate},
+ * {@code l2}, and {@code seed} apply. Must not be {@code
null}.
+ * @param beamSize The beam width to track the gold derivation in. Must be
at least 2.
+ * @return A new refined model, distinct from {@code model}. Never {@code
null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if a parameter is {@code null},
+ * {@code beamSize} is below 2, no trainable sample can be derived,
or a sample
+ * requires a transition {@code model} does not know.
+ */
+ public static FeedforwardDependencyModel refine(FeedforwardDependencyModel
model,
+ ObjectStream<DependencySample> samples, Settings settings, int beamSize)
+ throws IOException {
+ ParamChecks.requireNonNullArg(model, "model");
+ ParamChecks.requireNonNullArg(samples, "samples");
+ ParamChecks.requireNonNullArg(settings, "settings");
+ if (beamSize < 2) {
+ throw new IllegalArgumentException("beamSize must be at least 2: " +
beamSize);
+ }
+ final List<DependencySample> corpus = readAll(samples);
+ final String[] outcomes = model.transitions();
+ final Map<String, Integer> transitionIds = new HashMap<>();
+ final Transition[] transitions = new Transition[outcomes.length];
+ for (int i = 0; i < transitions.length; i++) {
+ transitionIds.put(outcomes[i], i);
+ transitions[i] = Transition.decode(outcomes[i]);
+ }
+
+ final List<DependencySample> trainable = new ArrayList<>();
+ final List<int[]> oracles = new ArrayList<>();
+ for (final DependencySample s : corpus) {
+ if (!ArcStandardOracle.isProjective(s.getTree())) {
+ continue;
+ }
+ final List<Transition> oracle =
ArcStandardOracle.transitions(s.getTree());
+ final int[] encoded = new int[oracle.size()];
+ for (int i = 0; i < encoded.length; i++) {
+ final String outcome = oracle.get(i).encode();
+ final Integer id = transitionIds.get(outcome);
+ if (id == null) {
+ // The outcome space was fixed by the original training set, so a
relation
+ // label it never saw has no output unit to push probability onto.
+ throw new IllegalArgumentException(
+ "unknown transition in the refinement samples: " + outcome);
+ }
+ encoded[i] = id;
+ }
+ trainable.add(s);
+ oracles.add(encoded);
+ }
+ if (trainable.isEmpty()) {
+ throw new IllegalArgumentException("no trainable samples for
refinement");
+ }
+
+ final FeedforwardDependencyModel refined = model.copy();
+ final GlobalOptimizer optimizer = new GlobalOptimizer(refined, settings);
+ final Random random = new Random(settings.seed());
+ final int[] order = new int[trainable.size()];
+ for (int i = 0; i < order.length; i++) {
+ order[i] = i;
+ }
+ for (int epoch = 1; epoch <= settings.epochs(); epoch++) {
+ final long epochStart = System.currentTimeMillis();
+ shuffle(order, random);
+ double loss = 0.0;
+ int updates = 0;
+ for (final int index : order) {
+ final double sentenceLoss =
optimizer.refineSentence(trainable.get(index),
+ oracles.get(index), transitions, beamSize);
+ if (sentenceLoss >= 0.0) {
+ loss += sentenceLoss;
+ updates++;
+ }
+ }
+ checkFinite(refined);
+ logger.info("refine epoch {}: loss {} over {} updates in {} ms", epoch,
+ loss / Math.max(updates, 1), updates, System.currentTimeMillis() -
epochStart);
+ }
+ return refined;
+ }
+
+ /** One candidate path in the refinement beam: the parent link forms the
history. */
+ private static final class BeamNode {
+ private final BeamNode parent;
+ private final int[] features;
+ private final int transition;
+ private final double score;
+ private final boolean gold;
+ private ArcStandardState state;
+
+ /**
+ * Extends {@code parent} by one transition; the start node passes {@code
null}.
+ *
+ * @param parent The node this one extends, or {@code null} for the start
node.
+ * @param features The embedding rows the transition was scored on.
+ * @param transition The transition index taken from {@code parent}.
+ * @param score The summed log-probability of the path.
+ * @param gold Whether the path is the gold derivation so far.
+ */
+ private BeamNode(BeamNode parent, int[] features, int transition, double
score,
+ boolean gold) {
+ this.parent = parent;
+ this.features = features;
+ this.transition = transition;
+ this.score = score;
+ this.gold = gold;
+ }
+ }
+
+ /** The forward, backward, and AdaGrad state for global refinement. */
+ private static final class GlobalOptimizer {
+ private final FeedforwardDependencyModel model;
+ private final Settings settings;
+ private final int embeddingSize;
+ private final int hiddenSize;
+ private final int outputSize;
+ private final int inputSize;
+
+ private final double[][] embeddingAccumulator;
+ private final double[][] hiddenAccumulator;
+ private final double[] hiddenBiasAccumulator;
+ private final double[][] outputAccumulator;
+ private final double[] outputBiasAccumulator;
+
+ private final double[][] hiddenGradient;
+ private final double[] hiddenBiasGradient;
+ private final double[][] outputGradient;
+ private final double[] outputBiasGradient;
+ private final Map<Integer, double[]> embeddingGradients = new HashMap<>();
+
+ private final double[] x;
+ private final double[] pre;
+ private final double[] hidden;
+ private final double[] probabilities;
+ private final double[] hiddenDelta;
+ private final double[] inputDelta;
+
+ /**
+ * Sizes the accumulators and scratch buffers for one refinement run.
+ *
+ * @param model The model copy the run updates in place.
+ * @param settings The hyperparameters; {@code learningRate} and {@code
l2} apply.
+ */
+ private GlobalOptimizer(FeedforwardDependencyModel model, Settings
settings) {
+ this.model = model;
+ this.settings = settings;
+ this.embeddingSize = model.embeddings()[0].length;
+ this.hiddenSize = model.hiddenBias().length;
+ this.outputSize = model.outputBias().length;
+ this.inputSize = FeedforwardContext.FEATURE_COUNT * embeddingSize;
+ this.embeddingAccumulator =
+ new double[model.embeddings().length][embeddingSize];
+ this.hiddenAccumulator = new double[hiddenSize][inputSize];
+ this.hiddenBiasAccumulator = new double[hiddenSize];
+ this.outputAccumulator = new double[outputSize][hiddenSize];
+ this.outputBiasAccumulator = new double[outputSize];
+ this.hiddenGradient = new double[hiddenSize][inputSize];
+ this.hiddenBiasGradient = new double[hiddenSize];
+ this.outputGradient = new double[outputSize][hiddenSize];
+ this.outputBiasGradient = new double[outputSize];
+ this.x = new double[inputSize];
+ this.pre = new double[hiddenSize];
+ this.hidden = new double[hiddenSize];
+ this.probabilities = new double[outputSize];
+ this.hiddenDelta = new double[hiddenSize];
+ this.inputDelta = new double[inputSize];
+ }
+
+ /**
+ * Decodes one sentence with the beam, updating on the early-update point
or the
+ * final beam.
+ *
+ * @param sample The sentence.
+ * @param oracle The gold transition indexes.
+ * @param transitions The decoded transition inventory.
+ * @param beamSize The beam width.
+ * @return The sentence loss, or {@link
FeedforwardDependencyTrainer#NO_UPDATE} when the
+ * sentence produced no update.
+ */
+ private double refineSentence(DependencySample sample, int[] oracle,
+ Transition[] transitions, int beamSize) {
+ final String[] tokens = sample.getTokens();
+ final String[] tags = sample.getTags();
+ final BeamNode root = new BeamNode(null, null, -1, 0.0, true);
+ root.state = new ArcStandardState(tokens.length);
+ List<BeamNode> beam = List.of(root);
+
+ for (int step = 0; step < oracle.length; step++) {
+ final List<BeamNode> expansions = new ArrayList<>();
+ BeamNode goldChild = null;
+ for (final BeamNode node : beam) {
+ final int[] features =
+ model.featureIds(FeedforwardContext.extract(node.state, tokens,
tags));
+ forward(features);
+ logSoftmaxInPlace(probabilities);
+ for (int i = 0; i < outputSize; i++) {
+ if (node.state.canApply(transitions[i])) {
+ final boolean goldNext = node.gold && i == oracle[step];
+ final BeamNode child = new BeamNode(node, features, i,
+ node.score + probabilities[i], goldNext);
+ expansions.add(child);
+ if (goldNext) {
+ goldChild = child;
+ }
+ }
+ }
+ }
+ expansions.sort((a, b) -> Double.compare(b.score, a.score));
+ final List<BeamNode> survivors =
+ new ArrayList<>(expansions.subList(0, Math.min(beamSize,
expansions.size())));
+ boolean goldRetained = false;
+ for (final BeamNode survivor : survivors) {
+ if (survivor.gold) {
+ goldRetained = true;
+ break;
+ }
+ }
+ if (!goldRetained) {
+ if (goldChild == null) {
+ // The gold transition was not applicable in the gold
configuration, so
+ // this sentence yields no update.
+ return NO_UPDATE;
+ }
+ survivors.add(goldChild);
+ return updateFromCandidates(survivors);
+ }
+ if (step == oracle.length - 1) {
+ return updateFromCandidates(survivors);
+ }
+ for (final BeamNode survivor : survivors) {
+ survivor.state = survivor.parent.state.copy();
+ survivor.state.apply(transitions[survivor.transition]);
+ }
+ beam = survivors;
+ }
+ return NO_UPDATE;
+ }
+
+ /**
+ * Applies the conditional-likelihood update over the candidate paths.
+ *
+ * @param candidates The surviving paths plus, on early update, the gold
path.
+ * @return The negative log-probability of the gold path under the
candidates.
+ */
+ private double updateFromCandidates(List<BeamNode> candidates) {
+ double max = Double.NEGATIVE_INFINITY;
+ double goldScore = Double.NEGATIVE_INFINITY;
+ for (final BeamNode candidate : candidates) {
+ max = Math.max(max, candidate.score);
+ if (candidate.gold) {
+ goldScore = candidate.score;
+ }
+ }
+ double normalizer = 0.0;
+ for (final BeamNode candidate : candidates) {
+ normalizer += Math.exp(candidate.score - max);
+ }
+ final double logNormalizer = max + Math.log(normalizer);
+
+ zero(hiddenGradient);
+ Arrays.fill(hiddenBiasGradient, 0.0);
+ zero(outputGradient);
+ Arrays.fill(outputBiasGradient, 0.0);
+ embeddingGradients.clear();
+ for (final BeamNode candidate : candidates) {
+ final double weight = Math.exp(candidate.score - logNormalizer)
+ - (candidate.gold ? 1.0 : 0.0);
+ if (weight == 0.0) {
+ continue;
+ }
+ for (BeamNode node = candidate; node.parent != null; node =
node.parent) {
+ backward(node.features, node.transition, weight);
+ }
+ }
+ update(model.hiddenWeights(), hiddenGradient, hiddenAccumulator, 1,
settings);
+ updateVector(model.hiddenBias(), hiddenBiasGradient,
hiddenBiasAccumulator, 1,
+ settings);
+ update(model.outputWeights(), outputGradient, outputAccumulator, 1,
settings);
+ updateVector(model.outputBias(), outputBiasGradient,
outputBiasAccumulator, 1,
+ settings);
+ for (final Map.Entry<Integer, double[]> entry :
embeddingGradients.entrySet()) {
+ final float[] embeddingRow = model.embeddings()[entry.getKey()];
+ final double[] accumulatorRow = embeddingAccumulator[entry.getKey()];
+ final double[] gradientRow = entry.getValue();
+ for (int d = 0; d < embeddingSize; d++) {
+ final double gradient = gradientRow[d];
+ accumulatorRow[d] += gradient * gradient;
+ embeddingRow[d] -= settings.learningRate() * gradient
+ / (Math.sqrt(accumulatorRow[d]) + ADAGRAD_EPSILON);
+ }
+ }
+ return logNormalizer - goldScore;
+ }
+
+ /**
+ * Computes hidden activations and raw output scores for one feature
vector into
+ * the scratch buffers.
+ *
+ * @param features The embedding rows of the configuration.
+ */
+ private void forward(int[] features) {
+ final float[][] embeddings = model.embeddings();
+ for (int f = 0; f < features.length; f++) {
+ final float[] embedding = embeddings[features[f]];
+ final int offset = f * embeddingSize;
+ for (int d = 0; d < embeddingSize; d++) {
+ x[offset + d] = embedding[d];
+ }
+ }
+ final float[][] hiddenWeights = model.hiddenWeights();
+ final float[] hiddenBias = model.hiddenBias();
+ for (int j = 0; j < hiddenSize; j++) {
+ final float[] weightRow = hiddenWeights[j];
+ double sum = hiddenBias[j];
+ for (int k = 0; k < inputSize; k++) {
+ sum += weightRow[k] * x[k];
+ }
+ pre[j] = sum;
+ hidden[j] = sum * sum * sum;
+ }
+ final float[][] outputWeights = model.outputWeights();
+ final float[] outputBias = model.outputBias();
+ for (int o = 0; o < outputSize; o++) {
+ final float[] weightRow = outputWeights[o];
+ double sum = outputBias[o];
+ for (int j = 0; j < hiddenSize; j++) {
+ sum += weightRow[j] * hidden[j];
+ }
+ probabilities[o] = sum;
+ }
+ }
+
+ /**
+ * Accumulates gradients for one decoded step: the weighted difference
between the
+ * step's softmax and its chosen transition.
+ *
+ * @param features The step's input features.
+ * @param chosen The transition the path took at this step.
+ * @param weight The path's weight in the candidate distribution.
+ */
+ private void backward(int[] features, int chosen, double weight) {
+ forward(features);
+ double max = Double.NEGATIVE_INFINITY;
+ for (int o = 0; o < outputSize; o++) {
+ max = Math.max(max, probabilities[o]);
+ }
+ double normalizer = 0.0;
+ for (int o = 0; o < outputSize; o++) {
+ probabilities[o] = Math.exp(probabilities[o] - max);
+ normalizer += probabilities[o];
+ }
+ Arrays.fill(hiddenDelta, 0.0);
+ Arrays.fill(inputDelta, 0.0);
+ final float[][] outputWeights = model.outputWeights();
+ for (int o = 0; o < outputSize; o++) {
+ // dL/dlogit for a path's step under the conditional likelihood: the
path weight
+ // times how the step's log-probability responds to this logit
+ final double delta =
+ weight * ((o == chosen ? 1.0 : 0.0) - probabilities[o] /
normalizer);
+ outputBiasGradient[o] += delta;
+ final double[] gradientRow = outputGradient[o];
+ final float[] weightRow = outputWeights[o];
+ for (int j = 0; j < hiddenSize; j++) {
+ gradientRow[j] += delta * hidden[j];
+ hiddenDelta[j] += delta * weightRow[j];
+ }
+ }
+ final float[][] hiddenWeights = model.hiddenWeights();
+ for (int j = 0; j < hiddenSize; j++) {
+ final double preDelta = hiddenDelta[j] * CUBE_DERIVATIVE_FACTOR *
pre[j] * pre[j];
+ hiddenBiasGradient[j] += preDelta;
+ final double[] gradientRow = hiddenGradient[j];
+ final float[] weightRow = hiddenWeights[j];
+ for (int k = 0; k < inputSize; k++) {
+ gradientRow[k] += preDelta * x[k];
+ inputDelta[k] += preDelta * weightRow[k];
+ }
+ }
+ for (int f = 0; f < features.length; f++) {
+ final double[] embeddingGradient = embeddingGradients
+ .computeIfAbsent(features[f], key -> new double[embeddingSize]);
+ final int offset = f * embeddingSize;
+ for (int d = 0; d < embeddingSize; d++) {
+ embeddingGradient[d] += inputDelta[offset + d];
+ }
+ }
+ }
+
+ /**
+ * Turns raw scores into log-probabilities in place.
+ *
+ * @param scores The raw scores, overwritten with their log-softmax.
+ */
+ private void logSoftmaxInPlace(double[] scores) {
+ double max = Double.NEGATIVE_INFINITY;
+ for (final double score : scores) {
+ max = Math.max(max, score);
+ }
+ double sum = 0.0;
+ for (final double score : scores) {
+ sum += Math.exp(score - max);
+ }
+ final double logSum = Math.log(sum);
+ for (int i = 0; i < scores.length; i++) {
+ scores[i] = (scores[i] - max) - logSum;
+ }
+ }
+ }
+
+ /**
+ * Reads a sample stream into memory; both trainers pass over the corpus
repeatedly.
+ *
+ * @param samples The stream to drain.
+ * @return All samples in stream order. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ */
+ private static List<DependencySample> readAll(ObjectStream<DependencySample>
samples)
+ throws IOException {
+ final List<DependencySample> corpus = new ArrayList<>();
+ DependencySample sample;
+ while ((sample = samples.read()) != null) {
+ corpus.add(sample);
+ }
+ return corpus;
+ }
+
+ /**
+ * Overwrites the random word rows with pretrained vectors where available.
+ *
+ * @param model The freshly initialized model whose word rows are seeded.
+ * @param pretrained The pretrained vector source, returning {@code null}
for a word it
+ * does not cover.
+ * @param settings The hyperparameters, which fix the expected vector width.
+ * @throws IllegalArgumentException Thrown if a pretrained vector has a
different width
+ * than the embedding size or contains a non-finite value.
+ */
+ private static void seed(FeedforwardDependencyModel model,
+ Function<String, float[]> pretrained, Settings settings) {
+ int seeded = 0;
+ for (final Map.Entry<String, Integer> entry : model.wordIds().entrySet()) {
+ if (FeedforwardDependencyModel.isSpecialSymbol(entry.getKey())) {
+ // The special unknown, padding, and root symbols have no pretrained
+ // counterpart, so they keep their random initialization.
+ continue;
+ }
+ final float[] vector = pretrained.apply(entry.getKey());
+ if (vector == null) {
+ continue;
+ }
+ if (vector.length != settings.embeddingSize()) {
+ throw new IllegalArgumentException("pretrained vector for '" +
entry.getKey()
+ + "' has " + vector.length + " dimensions, expected "
+ + settings.embeddingSize());
+ }
+ for (final float value : vector) {
+ if (!Float.isFinite(value)) {
+ throw new IllegalArgumentException(
+ "pretrained vector for '" + entry.getKey() + "' contains a
non-finite value");
+ }
+ }
+ System.arraycopy(vector, 0, model.embeddings()[entry.getValue()], 0,
vector.length);
+ seeded++;
+ }
+ logger.info("seeded {} of {} word embeddings from the pretrained source",
seeded,
+ model.wordIds().size());
+ }
+
+ /**
+ * Builds the vocabularies and randomly initialized weights. A word, tag, or
label in
+ * the training data that spells a reserved symbol shares the reserved row,
as an
+ * unknown symbol does, so the reserved rows are never displaced.
+ *
+ * @param corpus The training samples.
+ * @param settings The hyperparameters.
+ * @return The untrained model. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if the vocabularies and settings
would
+ * produce more model values than the model format can store.
+ */
+ private static FeedforwardDependencyModel initialize(List<DependencySample>
corpus,
+ Settings settings) {
+ final Map<String, Integer> wordCounts = new HashMap<>();
+ final Map<String, Integer> tagIds = new HashMap<>();
+ final Map<String, Integer> labelIds = new HashMap<>();
+ final Map<String, Integer> transitionIds = new HashMap<>();
+ for (final DependencySample s : corpus) {
+ if (!ArcStandardOracle.isProjective(s.getTree())) {
+ continue;
+ }
+ final List<Transition> oracle =
ArcStandardOracle.transitions(s.getTree());
+ for (final String token : s.getTokens()) {
+ wordCounts.merge(FeedforwardDependencyModel.normalize(token), 1,
Integer::sum);
+ }
+ for (final String tag : s.getTags()) {
+ tagIds.putIfAbsent(tag, 0);
+ }
+ final DependencyTree tree = s.getTree();
+ for (int i = 0; i < tree.size(); i++) {
+ labelIds.putIfAbsent(tree.relationOf(i), 0);
+ }
+ for (final Transition transition : oracle) {
+ transitionIds.putIfAbsent(transition.encode(), 0);
+ }
+ }
+
+ int row = 0;
+ final Map<String, Integer> wordIds = new HashMap<>();
+ row = addSpecialSymbols(wordIds, row, FeedforwardDependencyModel.UNKNOWN,
+ DependencyContextGenerator.NONE_VALUE,
DependencyContextGenerator.ROOT_VALUE);
+ for (final Map.Entry<String, Integer> entry : wordCounts.entrySet()) {
+ if (entry.getValue() >= settings.wordCutoff() &&
!wordIds.containsKey(entry.getKey())) {
+ wordIds.put(entry.getKey(), row++);
+ }
+ }
+ final Map<String, Integer> tags = new HashMap<>();
+ row = addSpecialSymbols(tags, row, FeedforwardDependencyModel.UNKNOWN,
Review Comment:
Documented in the trainer Javadoc and covered by
`testUnknownRowsAreTrainedOnlyForWordsBelowTheCutoff`: the tag and label
unknown rows keep their initialization, the word unknown row trains only when
words fall below the cutoff. I left symbol dropout for a follow-up since it
changes the training recipe.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyModel.java:
##########
@@ -0,0 +1,942 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReferenceArray;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.StringUtil;
+
+/**
+ * The weights of the feedforward transition parser: embeddings for words,
tags, and arc
+ * labels, one hidden layer with cube activation, and a transition output
layer, stored
+ * in a versioned binary format. The architecture follows
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>The same class scores configurations during training and decoding.
Unknown words
+ * use a learned fallback row; words are matched case-insensitively after
+ * {@link #normalize(String) normalization}.</p>
+ *
+ * <p>Instances returned by {@link FeedforwardDependencyTrainer} are immutable
and safe
+ * to share between threads. {@link FeedforwardDependencyTrainer#refine
refine} updates
+ * an independent copy.</p>
+ *
+ * @see FeedforwardDependencyParser
+ * @see FeedforwardDependencyTrainer
+ * @since 3.0.0
+ */
+@ThreadSafe
+public class FeedforwardDependencyModel {
+
+ /** The format header written before every serialized model; it carries the
format version. */
+ static final String MAGIC = "ONLP-FFDP-1";
+
+ /** The maximum number of (template position, embedding row) pairs the
scoring cache holds. */
+ static final int CACHE_ENTRY_LIMIT = 32_768;
+
+ /** The vocabulary label used in load errors for the word map. */
+ private static final String WORD_VOCABULARY = "word vocabulary";
+
+ /** The vocabulary label used in load errors for the tag map. */
+ private static final String TAG_VOCABULARY = "tag vocabulary";
+
+ /** The vocabulary label used in load errors for the label map. */
+ private static final String LABEL_VOCABULARY = "label vocabulary";
+
+ /** Maximum combined entries across the word, tag, and label maps. */
+ private static final int MAX_VOCABULARY_ENTRIES = 2_000_000;
+
+ /** Maximum transitions accepted from a serialized model. */
+ private static final int MAX_TRANSITIONS = 100_000;
+
+ /** Maximum embedding width accepted from a serialized model. */
+ static final int MAX_EMBEDDING_SIZE = 4_096;
+
+ /** Maximum hidden-layer width accepted from a serialized model. */
+ static final int MAX_HIDDEN_SIZE = 65_536;
+
+ /** Maximum float values allocated while loading a serialized model. */
+ static final long MAX_MODEL_FLOAT_VALUES = 100_000_000L;
+
+ /** U+03A3, GREEK CAPITAL LETTER SIGMA, the one code point with a contextual
lowering. */
+ private static final int GREEK_CAPITAL_SIGMA = 0x03A3;
+
+ /** U+03C2, GREEK SMALL LETTER FINAL SIGMA, the word-final lowering of the
capital. */
+ private static final char GREEK_SMALL_FINAL_SIGMA = '\u03C2';
+
+ /** The vocabulary key of every word, tag, or label the model has no
embedding row for. */
+ static final String UNKNOWN = "*UNK*";
+
+ /** The lazy scoring cache; {@code null} until {@link
#enableScoringCache()}. */
+ private volatile ContributionCache cache;
+
+ private final Map<String, Integer> wordIds;
+ private final Map<String, Integer> tagIds;
+ private final Map<String, Integer> labelIds;
+ private final String[] transitions;
+
+ private final int embeddingSize;
+ private final float[][] embeddings;
+ private final float[][] hiddenWeights;
+ private final float[] hiddenBias;
+ private final float[][] outputWeights;
+ private final float[] outputBias;
+
+ /**
+ * Assembles a model from its vocabularies and weights, copying the maps so
later
+ * changes by the caller do not reach the model.
+ *
+ * @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.
+ */
+ FeedforwardDependencyModel(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;
+ this.embeddingSize = embeddingSize;
+ this.embeddings = embeddings;
+ this.hiddenWeights = hiddenWeights;
+ this.hiddenBias = hiddenBias;
+ this.outputWeights = outputWeights;
+ this.outputBias = outputBias;
+ }
+
+ /**
+ * Scores every transition for a configuration described by embedding row
indices.
+ *
+ * @param features The embedding rows of the configuration, as produced by
+ * {@link #featureIds(String[])}. Must not be {@code null}.
+ * @return One unnormalized score per transition, indexed like
+ * {@link #transitions()}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code features} is {@code
null}, does not
+ * have the required length, or contains an invalid embedding index.
+ * @throws IllegalStateException Thrown if the model produces a non-finite
transition score.
+ */
+ double[] score(int[] features) {
+ 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 int hidden = hiddenBias.length;
+ final double[] h = new double[hidden];
+ for (int j = 0; j < hidden; j++) {
+ h[j] = hiddenBias[j];
+ }
+ final ContributionCache cache = this.cache;
+ for (int f = 0; f < features.length; f++) {
+ final int row = features[f];
+ final double[] contribution = cache == null ? null :
cache.contribution(this, f, row);
+ if (contribution != null) {
+ for (int j = 0; j < hidden; j++) {
+ h[j] += contribution[j];
+ }
+ } else {
+ final float[] embedding = embeddings[row];
+ final int offset = f * embeddingSize;
+ for (int j = 0; j < hidden; j++) {
+ final float[] weights = hiddenWeights[j];
+ double sum = 0.0;
+ for (int d = 0; d < embeddingSize; d++) {
+ sum += (double) weights[offset + d] * embedding[d];
+ }
+ h[j] += sum;
+ }
+ }
+ }
+ for (int j = 0; j < hidden; j++) {
+ h[j] = h[j] * h[j] * h[j];
+ }
+ final double[] scores = new double[transitions.length];
+ for (int o = 0; o < scores.length; o++) {
+ final float[] row = outputWeights[o];
+ double sum = outputBias[o];
+ for (int j = 0; j < hidden; j++) {
+ sum += row[j] * h[j];
+ }
+ if (!Double.isFinite(sum)) {
+ throw new IllegalStateException("the model produced a non-finite
transition score");
+ }
+ scores[o] = sum;
+ }
+ return scores;
+ }
+
+ /**
+ * Caches hidden-layer contributions by template position and embedding row.
+ *
+ * <p>Contributions retain double precision so cached and direct scoring use
the
+ * same values. Training and refinement use uncached copies; {@link #copy()}
+ * does not copy the cache.</p>
+ */
+ synchronized void enableScoringCache() {
+ if (cache == null) {
+ cache = new ContributionCache(FeedforwardContext.FEATURE_COUNT,
embeddings.length);
+ }
+ }
+
+ /**
+ * Stores contributions on first use up to a shared entry limit. Reference
tables
+ * are allocated for every template position and embedding row. Pairs beyond
the
+ * entry limit use direct scoring.
+ */
+ private static final class ContributionCache {
+
+ /** The cached contribution vectors, indexed by template position and
embedding row. */
+ private final AtomicReferenceArray<double[]>[] byPosition;
+
+ /** The number of pairs the cache may still add before the budget is
spent. */
+ private final AtomicInteger remaining = new
AtomicInteger(CACHE_ENTRY_LIMIT);
+
+ /**
+ * Creates an empty cache.
+ *
+ * @param positions The number of feature positions.
+ * @param rows The number of embedding rows a position can hold.
+ */
+ @SuppressWarnings("unchecked")
+ private ContributionCache(int positions, int rows) {
+ byPosition = new AtomicReferenceArray[positions];
+ for (int f = 0; f < positions; f++) {
+ byPosition[f] = new AtomicReferenceArray<>(rows);
+ }
+ }
+
+ /**
+ * Returns the cached hidden-layer contribution of one pair, computing and
+ * publishing it on first sight while the budget lasts.
+ *
+ * @param model The immutable model the contributions derive from.
+ * @param position The template position.
+ * @param row The embedding row at that position.
+ * @return The contribution vector, or {@code null} when the budget is
spent and
+ * the pair is not cached.
+ */
+ private double[] contribution(FeedforwardDependencyModel model, int
position, int row) {
+ final AtomicReferenceArray<double[]> slots = byPosition[position];
+ double[] contribution = slots.get(row);
+ if (contribution != null) {
+ return contribution;
+ }
+ if (!reserve()) {
+ return null;
+ }
+ boolean published = false;
+ try {
+ final int hidden = model.hiddenBias.length;
+ final float[] embedding = model.embeddings[row];
+ final int offset = position * model.embeddingSize;
+ contribution = new double[hidden];
+ for (int j = 0; j < hidden; j++) {
+ final float[] weights = model.hiddenWeights[j];
+ double sum = 0.0;
+ for (int d = 0; d < model.embeddingSize; d++) {
+ sum += (double) weights[offset + d] * embedding[d];
+ }
+ contribution[j] = sum;
+ }
+ published = slots.compareAndSet(row, null, contribution);
+ return published ? contribution : slots.get(row);
+ } finally {
+ if (!published) {
+ remaining.incrementAndGet();
+ }
+ }
+ }
+
+ /**
+ * Reserves capacity before allocating a contribution array.
+ *
+ * @return Whether one entry was reserved.
+ */
+ private boolean reserve() {
+ int available = remaining.get();
+ while (available > 0) {
+ if (remaining.compareAndSet(available, available - 1)) {
+ return true;
+ }
+ available = remaining.get();
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Maps the symbolic features of {@link FeedforwardContext} onto embedding
rows.
+ *
+ * @param symbols The symbolic features. Must not be {@code null}.
+ * @return The embedding row per feature. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code symbols} is {@code
null} or does
+ * not have the required length.
+ */
+ int[] featureIds(String[] symbols) {
Review Comment:
The lookup falls back to the unknown row, which construction and loading
guarantee, so the check and its exception are removed.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardContext.java:
##########
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+/**
+ * The feature template of the feedforward parser: a fixed set of configuration
+ * positions whose words, tags, and arc labels are embedded and concatenated
into the
+ * network input, following
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>Positions: the top three stack and buffer items; the leftmost and
rightmost
+ * dependents of the top two stack items; and the leftmost dependent of the
leftmost
+ * dependent and rightmost dependent of the rightmost dependent of the top two
stack
+ * items, capturing second-order structure. Words and tags are read for all
positions,
+ * labels only for the dependent positions, whose relations are already
assigned.</p>
+ */
+final class FeedforwardContext {
+
+ /** The number of positions whose word and tag are embedded. */
+ static final int POSITIONS = 14;
+
+ /** The number of dependent positions whose arc label is embedded. */
+ static final int LABEL_POSITIONS = 8;
+
+ /** The number of symbolic features in one parser configuration. */
+ static final int FEATURE_COUNT = 2 * POSITIONS + LABEL_POSITIONS;
+
+ /** The index of the first dependent position; the stack and buffer items
precede it. */
+ private static final int FIRST_DEPENDENT_POSITION = 6;
+
+ /** Prevents construction of this utility class. */
+ private FeedforwardContext() {
+ }
+
+ /**
+ * Extracts the symbolic features of a configuration: {@link #POSITIONS}
words, then
+ * {@link #POSITIONS} tags, then {@link #LABEL_POSITIONS} labels; absent
positions
+ * yield {@code null} entries, which the vocabulary maps to its padding
symbol.
+ *
+ * @param state The configuration to describe. Must not be {@code null}.
+ * @param tokens The sentence tokens. Must not be {@code null}.
+ * @param tags The part-of-speech tags aligned with {@code tokens}. Must not
be
+ * {@code null}.
+ * @return The symbolic features, in the order described above. Never {@code
null}.
+ */
+ static String[] extract(ArcStandardState state, String[] tokens, String[]
tags) {
+ final int s0 = state.stack(0);
+ final int s1 = state.stack(1);
+ final int[] positions = {
+ s0, s1, state.stack(2),
+ state.buffer(0), state.buffer(1), state.buffer(2),
+ leftmost(state, s0), rightmost(state, s0),
+ leftmost(state, s1), rightmost(state, s1),
+ leftmost(state, leftmost(state, s0)), rightmost(state,
rightmost(state, s0)),
+ leftmost(state, leftmost(state, s1)), rightmost(state,
rightmost(state, s1))
+ };
+ final String[] features = new String[FEATURE_COUNT];
+ for (int i = 0; i < POSITIONS; i++) {
+ features[i] = symbol(tokens, positions[i]);
+ features[POSITIONS + i] = symbol(tags, positions[i]);
+ }
+ for (int i = 0; i < LABEL_POSITIONS; i++) {
+ final int position = positions[FIRST_DEPENDENT_POSITION + i];
+ features[2 * POSITIONS + i] =
+ position >= 0 ? state.assignedRelation(position) : null;
+ }
+ return features;
+ }
+
+ /** The leftmost dependent of a position, or the absence marker for absent
positions. */
+ private static int leftmost(ArcStandardState state, int index) {
Review Comment:
Added `@param`/`@return`, `@since 3.0.0`, and a note on the per-sentence
scratch state.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/FeedforwardDependencyTrainer.java:
##########
@@ -0,0 +1,1176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.depparse;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.function.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * Trains the {@link FeedforwardDependencyModel}: oracle-derived transition
examples,
+ * minibatch AdaGrad over a softmax cross-entropy loss, cube activation, and
inverted
+ * dropout on the hidden layer, the training recipe of
+ * <a href="https://aclanthology.org/D14-1082/">Chen and Manning (2014)</a>.
+ *
+ * <p>Words below the frequency cutoff share a learned unknown embedding;
absent
+ * template positions share a learned padding embedding. Non-projective
samples have no
+ * arc-standard derivation and are skipped. Training is deterministic for a
fixed
+ * {@link Settings#seed()}.</p>
+ *
+ * @since 3.0.0
+ */
+public final class FeedforwardDependencyTrainer {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(FeedforwardDependencyTrainer.class);
+
+ /** The AdaGrad denominator offset that keeps the first steps finite. */
+ private static final double ADAGRAD_EPSILON = 1e-6;
+
+ /** The derivative factor of the cube activation, {@code d/dx x^3 = 3x^2}. */
+ private static final double CUBE_DERIVATIVE_FACTOR = 3.0;
+
+ /** The numerator of the Glorot uniform bound, {@code sqrt(6 / (fanIn +
fanOut))}. */
+ private static final double GLOROT_NUMERATOR = 6.0;
+
+ /** The half-width of the uniform range embeddings are drawn from. */
+ private static final double EMBEDDING_INIT_SCALE = 0.01;
+
+ /** The smallest probability the reported training loss takes the log of. */
+ private static final double LOSS_PROBABILITY_FLOOR = 1e-12;
+
+ /** The sentence loss reported when refinement made no update for a
sentence. */
+ private static final double NO_UPDATE = -1.0;
+
+ /** Special-symbol rows present before any training vocabulary is added. */
+ private static final int MIN_VOCABULARY_ROWS = 8;
+
+ /** SHIFT and at least one RIGHT_ARC are required to parse a sentence. */
+ private static final int MIN_TRANSITIONS = 2;
+
+ /** Prevents construction of this utility class. */
+ private FeedforwardDependencyTrainer() {
+ }
+
+ /**
+ * The training hyperparameters.
+ *
+ * @param embeddingSize The embedding dimensionality. Must be positive.
+ * @param hiddenSize The hidden layer width. Must be positive.
+ * @param epochs The number of passes over the examples. Must be positive.
+ * @param batchSize The minibatch size. Must be positive.
+ * @param learningRate The AdaGrad step size. Must be positive.
+ * @param l2 The L2 penalty applied to the dense weights. Must not be
negative.
+ * @param dropout The hidden dropout probability. Must be in {@code [0, 1)}.
+ * @param wordCutoff The minimum frequency for a word to get its own
embedding. Must
+ * not be negative.
+ * @param seed The random seed making a run reproducible.
+ */
+ public record Settings(int embeddingSize, int hiddenSize, int epochs, int
batchSize,
+ double learningRate, double l2, double dropout, int wordCutoff, long
seed) {
+
+ /** The default embedding dimensionality. */
+ private static final int DEFAULT_EMBEDDING_SIZE = 50;
+
+ /** The default hidden layer width. */
+ private static final int DEFAULT_HIDDEN_SIZE = 200;
+
+ /** The default number of epochs. */
+ private static final int DEFAULT_EPOCHS = 10;
+
+ /** The default minibatch size. */
+ private static final int DEFAULT_BATCH_SIZE = 256;
+
+ /** The default AdaGrad step size. */
+ private static final double DEFAULT_LEARNING_RATE = 0.02;
+
+ /** The default L2 penalty. */
+ private static final double DEFAULT_L2 = 1e-8;
+
+ /** The default hidden dropout probability. */
+ private static final double DEFAULT_DROPOUT = 0.5;
+
+ /** The default minimum word frequency for an embedding row. */
+ private static final int DEFAULT_WORD_CUTOFF = 2;
+
+ /** The default random seed. */
+ static final long DEFAULT_SEED = 17L;
+
+ /**
+ * Validates the hyperparameters.
+ *
+ * @throws IllegalArgumentException Thrown if a value is outside its
documented
+ * range.
+ */
+ public Settings {
+ requirePositive(embeddingSize, "embeddingSize");
+ requirePositive(hiddenSize, "hiddenSize");
+ requirePositive(epochs, "epochs");
+ requirePositive(batchSize, "batchSize");
+ if (embeddingSize > FeedforwardDependencyModel.MAX_EMBEDDING_SIZE) {
+ throw new IllegalArgumentException("embeddingSize exceeds the model
format limit: "
+ + embeddingSize);
+ }
+ if (hiddenSize > FeedforwardDependencyModel.MAX_HIDDEN_SIZE) {
+ throw new IllegalArgumentException("hiddenSize exceeds the model
format limit: "
+ + hiddenSize);
+ }
+ final long minimumModelValues = modelFloatValues(MIN_VOCABULARY_ROWS,
+ MIN_TRANSITIONS, embeddingSize, hiddenSize);
+ if (minimumModelValues >
FeedforwardDependencyModel.MAX_MODEL_FLOAT_VALUES) {
+ throw new IllegalArgumentException("embeddingSize and hiddenSize
exceed the model "
+ + "format allocation limit");
+ }
+ if (ParamChecks.requireFinite(learningRate, "learningRate") <= 0.0) {
+ throw new IllegalArgumentException("learningRate must be positive: " +
learningRate);
+ }
+ ParamChecks.requireNonNegative(ParamChecks.requireFinite(l2, "l2"),
"l2");
+ if (!(dropout >= 0.0 && dropout < 1.0)) {
+ throw new IllegalArgumentException("dropout must be in [0, 1): " +
dropout);
+ }
+ ParamChecks.requireNonNegative(wordCutoff, "wordCutoff");
+ }
+
+ /**
+ * Rejects a hyperparameter that is not positive.
+ *
+ * @param value The value to check.
+ * @param name The hyperparameter name used in the exception message.
+ * @throws IllegalArgumentException Thrown if {@code value} is not
positive.
+ */
+ private static void requirePositive(int value, String name) {
+ if (value <= 0) {
+ throw new IllegalArgumentException(name + " must be positive: " +
value);
+ }
+ }
+
+ /**
+ * @return The default hyperparameters. Never {@code null}.
+ */
+ public static Settings defaults() {
+ return new Settings(DEFAULT_EMBEDDING_SIZE, DEFAULT_HIDDEN_SIZE,
DEFAULT_EPOCHS,
+ DEFAULT_BATCH_SIZE, DEFAULT_LEARNING_RATE, DEFAULT_L2,
DEFAULT_DROPOUT,
+ DEFAULT_WORD_CUTOFF, DEFAULT_SEED);
+ }
+ }
+
+ /**
+ * Trains a model from dependency samples.
+ *
+ * @param samples The training samples. Must not be {@code null}.
+ * @param settings The hyperparameters. Must not be {@code null}.
+ * @return A trained {@link FeedforwardDependencyModel}. Never {@code null}.
+ * @throws IOException Thrown if reading the samples fails.
+ * @throws IllegalArgumentException Thrown if a parameter is {@code null} or
no
+ * trainable example can be derived from the samples.
+ */
+ public static FeedforwardDependencyModel
train(ObjectStream<DependencySample> samples,
+ Settings settings) throws IOException {
+ return train(samples, settings, null);
+ }
+
+ /**
+ * Trains a model from dependency samples, seeding word embeddings from a
pretrained
+ * source.
+ *
+ * <p>The provider is consulted once per vocabulary word during
initialization; words
Review Comment:
Trimmed to the contract.
> 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)