Kristian Rickert created OPENNLP-1845:
-----------------------------------------

             Summary: DocumentCategorizerDL softmax is numerically unstable 
(overflows to NaN for large logits)
                 Key: OPENNLP-1845
                 URL: https://issues.apache.org/jira/browse/OPENNLP-1845
             Project: OpenNLP
          Issue Type: Bug
          Components: dl
            Reporter: Kristian Rickert
            Assignee: Kristian Rickert


h2. Summary

{{DocumentCategorizerDL.softmax}} exponentiates the raw logits directly, 
without first subtracting the maximum. For a sufficiently large logit

{{Math.exp(logit)}}

overflows to {{{}+Infinity{}}}, and the subsequent {{Infinity / Infinity}} 
produces {{NaN}} category scores.

It also truncates each result to {{float}} before widening it back to 
{{{}double{}}}, discarding precision.
h2. Details

Current implementation:
{code:java}
private double[] softmax(final float[] input) {
  final double[] t = new double[input.length];
  double sum = 0.0;
  for (int x = 0; x < input.length; x++) {
    double val = Math.exp(input[x]);   // no max subtraction -> overflows for 
large logits
    sum += val;
    t[x] = val;
  }
  final double[] output = new double[input.length];
  for (int x = 0; x < output.length; x++) {
    output[x] = (float) (t[x] / sum); // gratuitous float truncation
  }
  return output;
}
{code}
For a model whose pre-softmax logits are small (e.g. the sentiment model used 
by the eval tests) no overflow occurs, which is why the existing results look 
correct. Models with larger logits return {{NaN}} scores instead of a valid 
distribution.
h2. Fix

Use the standard numerically stable softmax — subtract the maximum logit before 
exponentiating (mathematically identical to the naive form, but overflow-safe) 
— and keep {{double}} precision throughout:
{code:java}
static double[] softmax(final float[] input) {
  double max = Double.NEGATIVE_INFINITY;
  for (final float value : input) {
    max = Math.max(max, value);
  }
  final double[] t = new double[input.length];
  double sum = 0.0;
  for (int x = 0; x < input.length; x++) {
    final double val = Math.exp(input[x] - max);
    sum += val;
    t[x] = val;
  }
  final double[] output = new double[input.length];
  for (int x = 0; x < output.length; x++) {
    output[x] = t[x] / sum;
  }
  return output;
}
{code}
h2. Minor cleanups (same class)

While in {{{}DocumentCategorizerDL{}}}, two small same-class cleanups are 
included:
 * Rewrite {{tokenize()}} to advance an explicit index in a {{while}} loop 
instead of mutating the {{{}for{}}}-loop counter inside the body (identical 
chunking/overlap behavior, just clearer).
 * Fix the {{"Unload to perform..."}} -> {{"Unable to perform..."}} log message 
typo and add a Javadoc note that {{categorize()}} returns an empty array (and 
logs) when inference fails, documenting the retained historical behavior.

h2. Backward compatibility

Public API is unchanged. For models that did not overflow (small logits), 
scores change only at the float-vs-double rounding level (~1e-7), which is 
within the existing {{DocumentCategorizerDLEval}} tolerance of {{{}1e-6{}}}; 
the pinned eval values should still hold (confirm against the data-dir model, 
or regenerate if needed). For models that previously overflowed, scores change 
from {{NaN}} to a valid distribution.
h2. Testing

Added unit tests for {{softmax}} ({{{}DocumentCategorizerDLTest{}}}):
 * uniform distribution for equal logits and sum-to-one,
 * numerical stability for large logits (the previous code returned {{NaN}} 
here),
 * a reference distribution ({{{}softmax([1,2,3]){}}}).

{{mvn -pl opennlp-core/opennlp-ml/opennlp-dl test}} passes (28 tests).



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

Reply via email to