diff --git a/bench/PrivacyFilter.Net.Benchmarks/Program.cs b/bench/PrivacyFilter.Net.Benchmarks/Program.cs index 90d0881..d662e66 100644 --- a/bench/PrivacyFilter.Net.Benchmarks/Program.cs +++ b/bench/PrivacyFilter.Net.Benchmarks/Program.cs @@ -2,7 +2,7 @@ using BenchmarkDotNet.Running; using PrivacyFilterNet; -BenchmarkRunner.Run(); +BenchmarkSwitcher.FromAssembly(typeof(PrivacyFilterBenchmarks).Assembly).Run(args); [MemoryDiagnoser] public class PrivacyFilterBenchmarks @@ -34,3 +34,206 @@ public void Setup() [Benchmark] public PrivacyFilterResult LongText() => _filter!.Redact(_longInput); } + +[MemoryDiagnoser] +public class ManagedPostprocessingBenchmarks +{ + private const int TokenCount = 4096; + private float[] _scores = null!; + private float[] _logProbabilities = null!; + private float[] _denseStartScores = null!; + private float[] _denseEndScores = null!; + private float[] _denseTransitionScores = null!; + private ViterbiDecoder _decoder = null!; + private int _classCount; + + [GlobalSetup] + public void Setup() + { + LabelSpace labels = LabelSpace.Create(CreateClassNames()); + _decoder = new ViterbiDecoder(labels, calibrationPath: null); + _classCount = labels.TokenClassNames.Length; + _scores = new float[TokenCount * _classCount]; + _logProbabilities = new float[_scores.Length]; + var random = new Random(42); + for (int index = 0; index < _scores.Length; index++) + { + _scores[index] = (random.NextSingle() * 20) - 10; + } + + PrivacyFilter.LogSoftmax( + _scores, + _logProbabilities, + TokenCount, + _classCount, + destinationTokenOffset: 0); + CreateDenseScores( + _classCount, + out _denseStartScores, + out _denseEndScores, + out _denseTransitionScores); + } + + [Benchmark] + public void LogSoftmax() => + PrivacyFilter.LogSoftmax( + _scores, + _logProbabilities, + TokenCount, + _classCount, + destinationTokenOffset: 0); + + [Benchmark(Baseline = true)] + public int[] ViterbiDense() => + DecodeDense( + _logProbabilities, + TokenCount, + _classCount, + _denseStartScores, + _denseEndScores, + _denseTransitionScores); + + [Benchmark] + public int[] ViterbiSparse() => _decoder.Decode(_logProbabilities, TokenCount); + + [Benchmark] + public int[] ArgMax() => PrivacyFilter.ArgMax(_logProbabilities, TokenCount, _classCount); + + private static int[] DecodeDense( + float[] emissions, + int tokenCount, + int classCount, + float[] startScores, + float[] endScores, + float[] transitionScores) + { + var previousScores = new float[classCount]; + var nextScores = new float[classCount]; + var backpointers = new int[(tokenCount - 1) * classCount]; + for (int label = 0; label < classCount; label++) + { + previousScores[label] = emissions[label] + startScores[label]; + } + + for (int token = 1; token < tokenCount; token++) + { + int emissionOffset = token * classCount; + int backpointerOffset = (token - 1) * classCount; + for (int next = 0; next < classCount; next++) + { + float bestScore = float.NegativeInfinity; + int bestPrevious = 0; + for (int previous = 0; previous < classCount; previous++) + { + float score = previousScores[previous] + + transitionScores[(previous * classCount) + next]; + if (score > bestScore) + { + bestScore = score; + bestPrevious = previous; + } + } + + nextScores[next] = bestScore + emissions[emissionOffset + next]; + backpointers[backpointerOffset + next] = bestPrevious; + } + + (previousScores, nextScores) = (nextScores, previousScores); + } + + int lastLabel = 0; + float bestFinalScore = float.NegativeInfinity; + for (int label = 0; label < classCount; label++) + { + float score = previousScores[label] + endScores[label]; + if (score > bestFinalScore) + { + bestFinalScore = score; + lastLabel = label; + } + } + + var path = new int[tokenCount]; + path[^1] = lastLabel; + for (int token = tokenCount - 2; token >= 0; token--) + { + lastLabel = backpointers[(token * classCount) + lastLabel]; + path[token] = lastLabel; + } + + return path; + } + + private static void CreateDenseScores( + int classCount, + out float[] startScores, + out float[] endScores, + out float[] transitionScores) + { + const float invalid = -1e9f; + startScores = Enumerable.Repeat(invalid, classCount).ToArray(); + endScores = Enumerable.Repeat(invalid, classCount).ToArray(); + transitionScores = Enumerable.Repeat(invalid, classCount * classCount).ToArray(); + for (int previous = 0; previous < classCount; previous++) + { + char? previousTag = Boundary(previous); + if (previous == 0 || previousTag is 'B' or 'S') + { + startScores[previous] = 0; + } + + if (previous == 0 || previousTag is 'E' or 'S') + { + endScores[previous] = 0; + } + + for (int next = 0; next < classCount; next++) + { + if (IsValidTransition(previous, next)) + { + transitionScores[(previous * classCount) + next] = 0; + } + } + } + } + + private static bool IsValidTransition(int previous, int next) + { + char? previousTag = Boundary(previous); + char? nextTag = Boundary(next); + if (previous == 0 || previousTag is 'E' or 'S') + { + return next == 0 || nextTag is 'B' or 'S'; + } + + return SpanLabel(previous) == SpanLabel(next) && nextTag is 'I' or 'E'; + } + + private static char? Boundary(int label) => label == 0 ? null : "BIES"[(label - 1) % 4]; + + private static int SpanLabel(int label) => label == 0 ? 0 : ((label - 1) / 4) + 1; + + private static string[] CreateClassNames() + { + var names = new List { "O" }; + foreach (string label in new[] + { + "account_number", + "private_address", + "private_date", + "private_email", + "private_person", + "private_phone", + "private_url", + "secret", + }) + { + foreach (char boundary in "BIES") + { + names.Add($"{boundary}-{label}"); + } + } + + return names.ToArray(); + } +} diff --git a/bench/results.md b/bench/results.md index 268c85b..88caf2d 100644 --- a/bench/results.md +++ b/bench/results.md @@ -16,6 +16,34 @@ Measured 2026-07-21 on Windows 11 with an Intel Core i7-13800H (14 physical, | Short text | 356.6 ms | 10.82 ms | 16.6 KB | | Long text (50 copies) | 2,048.4 ms | 22.09 ms | 644.85 KB | +## Managed postprocessing optimization + +Measured 2026-07-22 with 4,096 tokens and 33 classes. The dense and sparse +Viterbi implementations used the same nontrivial score input. + +| Workload | Mean | Managed allocation | Relative time | +| --- | ---: | ---: | ---: | +| Log-softmax removed from production | 1.093 ms | 0 B | - | +| Dense Viterbi baseline | 4.527 ms | 544.28 KB | 1.00x | +| Sparse Viterbi with pooled backpointers | 2.065 ms | 16.02 KB | 0.46x | + +Sparse valid-predecessor tables reduced Viterbi time by 54% and managed +allocation by 97%. Decoding raw logits also removes the separate log-softmax +pass without changing argmax or Viterbi results. + +The end-to-end comparison used 5 warmups and 10 measurement iterations for +each implementation: + +| Workload | Before | After | Allocation before | Allocation after | +| --- | ---: | ---: | ---: | ---: | +| Short text | 234.4 ms | 232.6 ms | 16.69 KB | 7.93 KB | +| Long text (50 copies) | 1,048.7 ms | 1,078.4 ms | 644.85 KB | 222.09 KB | + +End-to-end latency remained effectively model-bound: the short workload +improved by 0.8%, while the noisier long workload measured 2.8% slower with +overlapping confidence intervals. Managed allocation fell by 52% and 66%, +respectively. + ## Same-model Python comparison For an apples-to-apples model comparison, a Python harness ran the same diff --git a/src/PrivacyFilter.Net/PrivacyFilter.Net.csproj b/src/PrivacyFilter.Net/PrivacyFilter.Net.csproj index f276f14..81826c7 100644 --- a/src/PrivacyFilter.Net/PrivacyFilter.Net.csproj +++ b/src/PrivacyFilter.Net/PrivacyFilter.Net.csproj @@ -38,6 +38,7 @@ + diff --git a/src/PrivacyFilter.Net/PrivacyFilter.cs b/src/PrivacyFilter.Net/PrivacyFilter.cs index 4cd4b61..99c1aa0 100644 --- a/src/PrivacyFilter.Net/PrivacyFilter.cs +++ b/src/PrivacyFilter.Net/PrivacyFilter.cs @@ -1,3 +1,4 @@ +using System.Buffers; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; @@ -94,18 +95,29 @@ public PrivacyFilterResult Redact(string text) return BuildResult(tokenized, []); } - float[] logProbabilities = RunModel(tokenized.TokenIds); - int[] decodedLabels = _options.DecodeMode == PrivacyFilterDecodeMode.Viterbi - ? _viterbi.Decode(logProbabilities, tokenized.TokenIds.Length) - : ArgMax(logProbabilities, tokenized.TokenIds.Length, _labels.TokenClassNames.Length); - List spans = SpanDecoder.Decode( - decodedLabels, - _labels, - tokenized, - _options.TrimWhitespace, - _options.DiscardOverlappingSpans, - _options.OutputMode); - return BuildResult(tokenized, spans); + int classCount = _labels.TokenClassNames.Length; + int scoreCount = checked(tokenized.TokenIds.Length * classCount); + float[] scoreBuffer = ArrayPool.Shared.Rent(scoreCount); + try + { + Span scores = scoreBuffer.AsSpan(0, scoreCount); + RunModel(tokenized.TokenIds, scores); + int[] decodedLabels = _options.DecodeMode == PrivacyFilterDecodeMode.Viterbi + ? _viterbi.Decode(scores, tokenized.TokenIds.Length) + : ArgMax(scores, tokenized.TokenIds.Length, classCount); + List spans = SpanDecoder.Decode( + decodedLabels, + _labels, + tokenized, + _options.TrimWhitespace, + _options.DiscardOverlappingSpans, + _options.OutputMode); + return BuildResult(tokenized, spans); + } + finally + { + ArrayPool.Shared.Return(scoreBuffer, clearArray: true); + } } /// Detects private spans and returns only the redacted text. @@ -123,60 +135,81 @@ public void Dispose() _disposed = true; } - private float[] RunModel(int[] tokenIds) + private void RunModel(int[] tokenIds, Span scores) { int classCount = _labels.TokenClassNames.Length; - var allLogProbabilities = new float[tokenIds.Length * classCount]; + if (scores.Length != tokenIds.Length * classCount) + { + throw new ArgumentException("Score dimensions do not match the token input.", nameof(scores)); + } + for (int windowStart = 0; windowStart < tokenIds.Length; windowStart += _options.ContextWindowLength) { int windowLength = Math.Min(_options.ContextWindowLength, tokenIds.Length - windowStart); - var ids = new long[windowLength]; - var mask = new long[windowLength]; - for (int index = 0; index < windowLength; index++) + long[] ids = ArrayPool.Shared.Rent(windowLength); + long[] mask = ArrayPool.Shared.Rent(windowLength); + try { - ids[index] = tokenIds[windowStart + index]; - mask[index] = 1; - } + for (int index = 0; index < windowLength; index++) + { + ids[index] = tokenIds[windowStart + index]; + mask[index] = 1; + } - var idTensor = new DenseTensor(ids, [1, windowLength]); - var maskTensor = new DenseTensor(mask, [1, windowLength]); - using IDisposableReadOnlyCollection results = _session.Run( - [ - NamedOnnxValue.CreateFromTensor("input_ids", idTensor), - NamedOnnxValue.CreateFromTensor("attention_mask", maskTensor), - ]); - DisposableNamedOnnxValue? logitsValue = - results.FirstOrDefault(result => result.Name == "logits"); - if (logitsValue is null) - { - throw new InvalidDataException("The ONNX model did not return logits."); - } + var idTensor = new DenseTensor( + ids.AsMemory(0, windowLength), + [1, windowLength]); + var maskTensor = new DenseTensor( + mask.AsMemory(0, windowLength), + [1, windowLength]); + using IDisposableReadOnlyCollection results = _session.Run( + [ + NamedOnnxValue.CreateFromTensor("input_ids", idTensor), + NamedOnnxValue.CreateFromTensor("attention_mask", maskTensor), + ]); + DisposableNamedOnnxValue? logitsValue = + results.FirstOrDefault(result => result.Name == "logits"); + if (logitsValue is null) + { + throw new InvalidDataException("The ONNX model did not return logits."); + } - Tensor logits = logitsValue.AsTensor(); - if (logits.Dimensions.Length != 3 || - logits.Dimensions[0] != 1 || - logits.Dimensions[1] != windowLength || - logits.Dimensions[2] != classCount) + Tensor logits = logitsValue.AsTensor(); + if (logits.Dimensions.Length != 3 || + logits.Dimensions[0] != 1 || + logits.Dimensions[1] != windowLength || + logits.Dimensions[2] != classCount) + { + throw new InvalidDataException( + $"The ONNX logits shape must be [1,{windowLength},{classCount}]."); + } + + int scoreOffset = windowStart * classCount; + int windowScoreCount = windowLength * classCount; + Span windowScores = scores.Slice(scoreOffset, windowScoreCount); + if (logits is DenseTensor denseLogits) + { + denseLogits.Buffer.Span.CopyTo(windowScores); + } + else + { + for (int index = 0; index < windowScoreCount; index++) + { + windowScores[index] = logits.GetValue(index); + } + } + } + finally { - throw new InvalidDataException( - $"The ONNX logits shape must be [1,{windowLength},{classCount}]."); + ArrayPool.Shared.Return(ids, clearArray: true); + ArrayPool.Shared.Return(mask, clearArray: true); } - - float[] values = logits.ToArray(); - LogSoftmax( - values, - allLogProbabilities, - sourceTokenCount: windowLength, - classCount, - destinationTokenOffset: windowStart); } - - return allLogProbabilities; } - private static void LogSoftmax( + internal static void LogSoftmax( float[] source, float[] destination, int sourceTokenCount, @@ -208,7 +241,7 @@ private static void LogSoftmax( } } - private static int[] ArgMax(float[] scores, int tokenCount, int classCount) + internal static int[] ArgMax(ReadOnlySpan scores, int tokenCount, int classCount) { var labels = new int[tokenCount]; for (int token = 0; token < tokenCount; token++) diff --git a/src/PrivacyFilter.Net/ViterbiDecoder.cs b/src/PrivacyFilter.Net/ViterbiDecoder.cs index 4e11984..600f928 100644 --- a/src/PrivacyFilter.Net/ViterbiDecoder.cs +++ b/src/PrivacyFilter.Net/ViterbiDecoder.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Text.Json; namespace PrivacyFilterNet; @@ -19,6 +20,8 @@ internal sealed class ViterbiDecoder private readonly LabelSpace _labels; private readonly float[] _startScores; private readonly float[] _endScores; + private readonly int[] _predecessorOffsets; + private readonly byte[] _predecessors; private readonly float[] _transitionScores; public ViterbiDecoder(LabelSpace labels, string? calibrationPath) @@ -26,9 +29,20 @@ public ViterbiDecoder(LabelSpace labels, string? calibrationPath) _labels = labels; ViterbiBiases biases = ViterbiBiases.Load(calibrationPath); int classCount = labels.TokenClassNames.Length; + if (classCount > byte.MaxValue + 1) + { + throw new InvalidDataException("Viterbi decoding supports at most 256 token classes."); + } + _startScores = Enumerable.Repeat(NegativeInfinity, classCount).ToArray(); _endScores = Enumerable.Repeat(NegativeInfinity, classCount).ToArray(); - _transitionScores = Enumerable.Repeat(NegativeInfinity, classCount * classCount).ToArray(); + var validPredecessors = new List[classCount]; + var validTransitionScores = new List[classCount]; + for (int next = 0; next < classCount; next++) + { + validPredecessors[next] = []; + validTransitionScores[next] = []; + } for (int previous = 0; previous < classCount; previous++) { @@ -50,14 +64,30 @@ public ViterbiDecoder(LabelSpace labels, string? calibrationPath) int nextSpan = labels.TokenToSpanLabel[next]; if (IsValidTransition(previous, previousTag, previousSpan, next, nextTag, nextSpan)) { - _transitionScores[(previous * classCount) + next] = - TransitionBias(previous, previousTag, previousSpan, next, nextTag, nextSpan, biases); + validPredecessors[next].Add((byte)previous); + validTransitionScores[next].Add( + TransitionBias(previous, previousTag, previousSpan, next, nextTag, nextSpan, biases)); } } } + + _predecessorOffsets = new int[classCount + 1]; + for (int next = 0; next < classCount; next++) + { + _predecessorOffsets[next + 1] = + _predecessorOffsets[next] + validPredecessors[next].Count; + } + + _predecessors = new byte[_predecessorOffsets[^1]]; + _transitionScores = new float[_predecessors.Length]; + for (int next = 0; next < classCount; next++) + { + validPredecessors[next].CopyTo(_predecessors, _predecessorOffsets[next]); + validTransitionScores[next].CopyTo(_transitionScores, _predecessorOffsets[next]); + } } - public int[] Decode(float[] emissions, int tokenCount) + public int[] Decode(ReadOnlySpan emissions, int tokenCount) { int classCount = _labels.TokenClassNames.Length; if (emissions.Length != tokenCount * classCount) @@ -70,61 +100,80 @@ public int[] Decode(float[] emissions, int tokenCount) return []; } - var previousScores = new float[classCount]; - var nextScores = new float[classCount]; - var backpointers = new int[Math.Max(0, tokenCount - 1) * classCount]; - for (int label = 0; label < classCount; label++) + Span previousScores = stackalloc float[classCount]; + Span nextScores = stackalloc float[classCount]; + int backpointerCount = Math.Max(0, tokenCount - 1) * classCount; + byte[]? backpointerBuffer = + backpointerCount == 0 ? null : ArrayPool.Shared.Rent(backpointerCount); + Span backpointers = backpointerBuffer.AsSpan(0, backpointerCount); + try { - previousScores[label] = emissions[label] + _startScores[label]; - } + for (int label = 0; label < classCount; label++) + { + previousScores[label] = emissions[label] + _startScores[label]; + } - for (int token = 1; token < tokenCount; token++) - { - int emissionOffset = token * classCount; - int backpointerOffset = (token - 1) * classCount; - for (int next = 0; next < classCount; next++) + for (int token = 1; token < tokenCount; token++) { - float bestScore = float.NegativeInfinity; - int bestPrevious = 0; - for (int previous = 0; previous < classCount; previous++) + int emissionOffset = token * classCount; + int backpointerOffset = (token - 1) * classCount; + for (int next = 0; next < classCount; next++) { - float score = previousScores[previous] + - _transitionScores[(previous * classCount) + next]; - if (score > bestScore) + float bestScore = float.NegativeInfinity; + byte bestPrevious = 0; + int predecessorEnd = _predecessorOffsets[next + 1]; + for (int predecessorIndex = _predecessorOffsets[next]; + predecessorIndex < predecessorEnd; + predecessorIndex++) { - bestScore = score; - bestPrevious = previous; + byte previous = _predecessors[predecessorIndex]; + float score = previousScores[previous] + + _transitionScores[predecessorIndex]; + if (score > bestScore) + { + bestScore = score; + bestPrevious = previous; + } } + + nextScores[next] = bestScore + emissions[emissionOffset + next]; + backpointers[backpointerOffset + next] = bestPrevious; } - nextScores[next] = bestScore + emissions[emissionOffset + next]; - backpointers[backpointerOffset + next] = bestPrevious; + Span temporaryScores = previousScores; + previousScores = nextScores; + nextScores = temporaryScores; } - (previousScores, nextScores) = (nextScores, previousScores); - } + int lastLabel = 0; + float bestFinalScore = float.NegativeInfinity; + for (int label = 0; label < classCount; label++) + { + float score = previousScores[label] + _endScores[label]; + if (score > bestFinalScore) + { + bestFinalScore = score; + lastLabel = label; + } + } - int lastLabel = 0; - float bestFinalScore = float.NegativeInfinity; - for (int label = 0; label < classCount; label++) - { - float score = previousScores[label] + _endScores[label]; - if (score > bestFinalScore) + var path = new int[tokenCount]; + path[^1] = lastLabel; + for (int token = tokenCount - 2; token >= 0; token--) { - bestFinalScore = score; - lastLabel = label; + lastLabel = backpointers[(token * classCount) + lastLabel]; + path[token] = lastLabel; } - } - var path = new int[tokenCount]; - path[^1] = lastLabel; - for (int token = tokenCount - 2; token >= 0; token--) + return path; + } + finally { - lastLabel = backpointers[(token * classCount) + lastLabel]; - path[token] = lastLabel; + if (backpointerBuffer is not null) + { + ArrayPool.Shared.Return(backpointerBuffer, clearArray: true); + } } - - return path; } private bool IsValidTransition( diff --git a/tests/PrivacyFilter.Net.Tests/DecoderTests.cs b/tests/PrivacyFilter.Net.Tests/DecoderTests.cs index ca4a51b..6576db0 100644 --- a/tests/PrivacyFilter.Net.Tests/DecoderTests.cs +++ b/tests/PrivacyFilter.Net.Tests/DecoderTests.cs @@ -22,6 +22,68 @@ public void ViterbiRejectsIllegalInsideStart() path); } + [Fact] + public void DecodersAreInvariantToLogSoftmax() + { + LabelSpace labels = LabelSpace.Create(ClassNames); + var decoder = new ViterbiDecoder(labels, calibrationPath: null); + int classCount = ClassNames.Length; + var random = new Random(42); + + for (int testCase = 0; testCase < 128; testCase++) + { + int tokenCount = random.Next(1, 65); + var logits = new float[tokenCount * classCount]; + for (int index = 0; index < logits.Length; index++) + { + logits[index] = (random.NextSingle() * 40) - 20; + } + + var logProbabilities = new float[logits.Length]; + PrivacyFilter.LogSoftmax( + logits, + logProbabilities, + tokenCount, + classCount, + destinationTokenOffset: 0); + + Assert.Equal( + decoder.Decode(logProbabilities, tokenCount), + decoder.Decode(logits, tokenCount)); + Assert.Equal( + PrivacyFilter.ArgMax(logProbabilities, tokenCount, classCount), + PrivacyFilter.ArgMax(logits, tokenCount, classCount)); + } + } + + [Fact] + public void SparseViterbiMatchesDenseReference() + { + LabelSpace labels = LabelSpace.Create(ClassNames); + var decoder = new ViterbiDecoder(labels, calibrationPath: null); + int classCount = ClassNames.Length; + var random = new Random(42); + + for (int testCase = 0; testCase < 64; testCase++) + { + int tokenCount = random.Next(1, 33); + var emissions = new float[tokenCount * classCount]; + for (int index = 0; index < emissions.Length; index++) + { + emissions[index] = (random.NextSingle() * 20) - 10; + } + + Assert.Equal( + DecodeDenseReference(emissions, tokenCount, classCount), + decoder.Decode(emissions, tokenCount)); + } + + var tiedEmissions = new float[8 * classCount]; + Assert.Equal( + DecodeDenseReference(tiedEmissions, 8, classCount), + decoder.Decode(tiedEmissions, 8)); + } + [Fact] public void SpanDecoderTrimsAndRedacts() { @@ -51,6 +113,84 @@ public void SpanDecoderTrimsAndRedacts() private static int ClassIndex(string name) => Array.IndexOf(ClassNames, name); + private static int[] DecodeDenseReference(float[] emissions, int tokenCount, int classCount) + { + const float invalid = -1e9f; + var previousScores = new float[classCount]; + var nextScores = new float[classCount]; + var backpointers = new int[(tokenCount - 1) * classCount]; + for (int label = 0; label < classCount; label++) + { + previousScores[label] = emissions[label] + + (label == 0 || Boundary(label) is 'B' or 'S' ? 0 : invalid); + } + + for (int token = 1; token < tokenCount; token++) + { + int emissionOffset = token * classCount; + int backpointerOffset = (token - 1) * classCount; + for (int next = 0; next < classCount; next++) + { + float bestScore = float.NegativeInfinity; + int bestPrevious = 0; + for (int previous = 0; previous < classCount; previous++) + { + float score = previousScores[previous] + + (IsValidTransition(previous, next) ? 0 : invalid); + if (score > bestScore) + { + bestScore = score; + bestPrevious = previous; + } + } + + nextScores[next] = bestScore + emissions[emissionOffset + next]; + backpointers[backpointerOffset + next] = bestPrevious; + } + + (previousScores, nextScores) = (nextScores, previousScores); + } + + int lastLabel = 0; + float bestFinalScore = float.NegativeInfinity; + for (int label = 0; label < classCount; label++) + { + float score = previousScores[label] + + (label == 0 || Boundary(label) is 'E' or 'S' ? 0 : invalid); + if (score > bestFinalScore) + { + bestFinalScore = score; + lastLabel = label; + } + } + + var path = new int[tokenCount]; + path[^1] = lastLabel; + for (int token = tokenCount - 2; token >= 0; token--) + { + lastLabel = backpointers[(token * classCount) + lastLabel]; + path[token] = lastLabel; + } + + return path; + } + + private static bool IsValidTransition(int previous, int next) + { + char? previousTag = Boundary(previous); + char? nextTag = Boundary(next); + if (previous == 0 || previousTag is 'E' or 'S') + { + return next == 0 || nextTag is 'B' or 'S'; + } + + return SpanLabel(previous) == SpanLabel(next) && nextTag is 'I' or 'E'; + } + + private static char? Boundary(int label) => label == 0 ? null : "BIES"[(label - 1) % 4]; + + private static int SpanLabel(int label) => label == 0 ? 0 : ((label - 1) / 4) + 1; + private static string[] CreateClassNames() { var names = new List { "O" };