Skip to content

Expose token + vocab log probabilities from offline CTC decoder - #3630

Open
crintus wants to merge 1 commit into
k2-fsa:masterfrom
birdtracks:upstream-ctc-confidence
Open

crintus wants to merge 1 commit into
k2-fsa:masterfrom
birdtracks:upstream-ctc-confidence

Conversation

@crintus

@crintus crintus commented May 21, 2026

Copy link
Copy Markdown

Summary

The offline CTC greedy decoder (offline-ctc-greedy-search-decoder.cc) currently runs std::max_element to find the argmax token but discards the log-probability value at that position. As a result, ys_log_probs on OfflineRecognitionResult (added by #2843 for the offline transducer path) is always empty for CTC models, and no API surface ever sees the full vocab distribution.

This PR exposes both:

  • Per-token greedy log-prob (token_log_probs) — keeps the value that std::max_element already computed. Costs one extra float per emitted token. Routed into the existing OfflineRecognitionResult::ys_log_probs + C struct ys_log_probs field via the existing copy logic — no new C API surface needed for this.
  • Full vocab log-probability distribution per emitted token (vocab_log_probs) — copy of the full softmax row at each emitted frame. New fields on OfflineRecognitionResult, the C decoder result struct, and the C API result struct (vocab_log_probs flat row-major [count, vocab_size] + vocab_size).

Motivation

Per-token confidence + full vocab distributions are required for entropy-based confidence estimation (e.g. Tsallis / Rényi over the vocab row — NVIDIA's recommended approach for ASR confidence). PR #2897 attempted a broad version of this but was closed when the author's ROVER fusion benchmark didn't show a measurable win in their (multi-system, clinical-audio) setting. We're using it for single-model entropy on where the use case is fundamentally different, and the patch was good. This PR re-submits a focused subset covering only offline CTC, which is the architecture we use.

Backwards compatibility

  • OfflineRecognitionResult and SherpaOnnxOfflineRecognizerResult only gain trailing fields. Existing consumers see no API/ABI changes.
  • New fields stay empty/nullptr when callers don't consume them. Memory footprint cost: O(count * vocab_size * sizeof(float)) per decode — opt-in via reading the field, but unconditionally populated. If that's a concern, happy to gate it on a config flag.
  • ys_log_probs (which Add token-level confidence scores (ys_probs) for offline transducer models #2843 added for offline transducer) now also flows for offline CTC, matching the field's documentation.

Testing

Built and tested on Android arm64-v8a (NDK r27) against a NeMo Conformer-CTC model (EncDecCTCModelBPE, vocab_size 1025, blank = 1024). On real device, confidence values pass sanity checks:

  • Clear English words → 0.82–0.99 per token
  • OOV / nonsense → token confidences drop to ~0.15–0.30
  • Inference latency unchanged (~150–200 ms on 1s clips)

Happy to add a regression test if the maintainers want one — pointer to the right test fixture would help.

Not included in this PR (deliberately)

PR #2897's scope covered Whisper, Moonshine, Canary, NeMo transducer, online transducer, and Flutter/Dart bindings in addition to offline CTC. This PR keeps scope tight to offline CTC to maximise the chance of merge; happy to follow up with parallel PRs for the other decoders if this lands and the maintainer is open to it.

Closes / supersedes: a focused subset of #2897.

Summary by CodeRabbit

Release Notes

  • New Features
    • Offline speech recognition now provides vocabulary log-probability distributions for each decoded token, enabling confidence scoring and entropy analysis of recognition results.

Review Change Stack

The offline CTC greedy decoder produced argmax token IDs only and
discarded the log-probability values, so the existing ys_log_probs field
on OfflineRecognitionResult (and the matching C struct field) was always
NULL for CTC models — even though it was already populated for the
offline transducer path by k2-fsa#2843.

Changes:
- OfflineCtcDecoderResult: add token_log_probs (per-emitted-token max
  log-prob) and vocab_log_probs (full vocab distribution per emitted
  token).
- OfflineCtcGreedySearchDecoder::Decode: populate both vectors alongside
  the existing tokens/timestamps. Blank and repeated frames are excluded
  (same predicate as the existing token emission).
- OfflineRecognitionResult: add vocab_log_probs for parity with the
  C API addition.
- offline-recognizer-ctc-impl Convert(): copy the new fields from the
  decoder result into the outer recognition result.
- C API: add vocab_log_probs (flattened row-major float matrix) and
  vocab_size to SherpaOnnxOfflineRecognizerResult, populate them in
  SherpaOnnxGetOfflineStreamResult, free them in
  SherpaOnnxDestroyOfflineRecognizerResult.

The existing ys_log_probs C-side wiring (added in k2-fsa#2843) is now also
populated for offline CTC because the recognizer copies
src.token_log_probs into r.ys_log_probs.

Motivation: enables entropy-based confidence estimation (e.g. Tsallis
entropy over the vocab distribution) for offline CTC models without
requiring a second inference pass via raw onnxruntime.
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label May 21, 2026
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR extends the offline ASR result pipeline to capture and expose full vocabulary log-probability distributions for each emitted token. The feature propagates from greedy CTC decoder through C++ recognition result to the C API, with proper memory management for C callers.

Changes

Vocabulary log-probability capture and propagation

Layer / File(s) Summary
Data structure contracts for vocab log-probabilities
sherpa-onnx/csrc/offline-ctc-decoder.h, sherpa-onnx/csrc/offline-stream.h, sherpa-onnx/c-api/c-api.h
Three struct extensions add new fields: OfflineCtcDecoderResult gains token_log_probs and vocab_log_probs vectors; OfflineRecognitionResult gains vocab_log_probs vector; SherpaOnnxOfflineRecognizerResult (C API) gains pointer vocab_log_probs and vocab_size scalar with documentation of row-major layout.
Greedy decoder vocab log-probability capture
sherpa-onnx/csrc/offline-ctc-greedy-search-decoder.cc
OfflineCtcGreedySearchDecoder::Decode now records log probability for each selected class during greedy decoding and, when the selected class is not blank and differs from the previous token, appends to token_log_probs and stores the full vocabulary log-probability slice for that timestep in vocab_log_probs.
Result propagation through Convert()
sherpa-onnx/csrc/offline-recognizer-ctc-impl.h
Convert() conditionally copies token_log_probs and vocab_log_probs from OfflineCtcDecoderResult into OfflineRecognitionResult fields when the source vectors match the token count.
C API marshaling and lifecycle management
sherpa-onnx/c-api/c-api.cc
SherpaOnnxGetOfflineStreamResult now flattens OfflineRecognitionResult::vocab_log_probs into a contiguous C array r->vocab_log_probs with corresponding vocab_size, setting to null/0 when unavailable or mismatched; SherpaOnnxDestroyOfflineRecognizerResult now frees the allocated array.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • k2-fsa/sherpa-onnx#2843: Both PRs update the offline C API marshalling/cleanup path to copy/free per-token log-probability fields on SherpaOnnxOfflineRecognizerResult.

Suggested reviewers

  • csukuangfj

Poem

🐰 A vocab's whispers now unfold,
Log-probs flowing, bright and bold,
From greedy search through result streams,
C API marshals confidence dreams,
Each token's fate in numbers told! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: exposing token and vocabulary log probabilities from the offline CTC decoder, which is the core objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sherpa-onnx/c-api/c-api.cc`:
- Around line 868-879: The code flattens result.vocab_log_probs into flat
assuming every row matches vocab_size and multiplies r->count*vocab_size without
overflow checks; to fix, before allocating and copying, validate that
result.vocab_log_probs.size() == static_cast<size_t>(r->count), compute
vocab_size from row 0, then loop over i and verify
result.vocab_log_probs[i].size() == static_cast<size_t>(vocab_size) (handle
mismatch by returning an error/cleanup), and check/mask the multiplication
r->count * vocab_size for overflow (or use size_t and bounds-check against
numeric_limits<size_t>::max()) before new float[...] to avoid buffer overruns
when filling flat and ensure safe memory allocation for flat and correct
assignment to r->vocab_size.

In `@sherpa-onnx/csrc/offline-recognizer-ctc-impl.h`:
- Around line 80-88: Convert() is copying src.token_log_probs and
src.vocab_log_probs directly into r.ys_log_probs and r.vocab_log_probs using
src.tokens indices, which breaks alignment when Convert() filters tokens before
assigning r.tokens; update the logic in Convert() to build r.ys_log_probs and
r.vocab_log_probs by iterating the same filtered token indices used to produce
r.tokens and push the corresponding entries from src.token_log_probs and
src.vocab_log_probs (only when those source arrays are non-empty and have
sufficient size), so the resulting r.ys_log_probs and r.vocab_log_probs match
r.tokens in length and order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d40099f-4349-47b3-8376-cc80ee1087b0

📥 Commits

Reviewing files that changed from the base of the PR and between a703cf6 and 81b7407.

📒 Files selected for processing (6)
  • sherpa-onnx/c-api/c-api.cc
  • sherpa-onnx/c-api/c-api.h
  • sherpa-onnx/csrc/offline-ctc-decoder.h
  • sherpa-onnx/csrc/offline-ctc-greedy-search-decoder.cc
  • sherpa-onnx/csrc/offline-recognizer-ctc-impl.h
  • sherpa-onnx/csrc/offline-stream.h

Comment on lines +868 to +879
// Copy vocab_log_probs (flattened row-major: count * vocab_size)
if (!result.vocab_log_probs.empty() &&
static_cast<int32_t>(result.vocab_log_probs.size()) == r->count &&
!result.vocab_log_probs[0].empty()) {
int32_t vocab_size =
static_cast<int32_t>(result.vocab_log_probs[0].size());
r->vocab_size = vocab_size;
float *flat = new float[r->count * vocab_size];
for (int32_t i = 0; i < r->count; ++i) {
std::copy(result.vocab_log_probs[i].begin(),
result.vocab_log_probs[i].end(), flat + i * vocab_size);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Validate every row width before flattening to avoid buffer corruption.

At Line 877, the copy assumes every result.vocab_log_probs[i] has vocab_size elements (from Line 873), but only row 0 is validated. A wider row can overrun flat; a shorter row yields partial/uninitialized output. Also, Line 875 multiplies int32_t dimensions without overflow guarding.

Suggested fix
-  if (!result.vocab_log_probs.empty() &&
-      static_cast<int32_t>(result.vocab_log_probs.size()) == r->count &&
-      !result.vocab_log_probs[0].empty()) {
-    int32_t vocab_size =
-        static_cast<int32_t>(result.vocab_log_probs[0].size());
-    r->vocab_size = vocab_size;
-    float *flat = new float[r->count * vocab_size];
-    for (int32_t i = 0; i < r->count; ++i) {
-      std::copy(result.vocab_log_probs[i].begin(),
-                result.vocab_log_probs[i].end(), flat + i * vocab_size);
-    }
-    r->vocab_log_probs = flat;
+  if (!result.vocab_log_probs.empty() &&
+      static_cast<int32_t>(result.vocab_log_probs.size()) == r->count &&
+      !result.vocab_log_probs[0].empty()) {
+    const size_t rows = result.vocab_log_probs.size();
+    const size_t vocab_size = result.vocab_log_probs[0].size();
+    const bool uniform = std::all_of(
+        result.vocab_log_probs.begin(), result.vocab_log_probs.end(),
+        [vocab_size](const std::vector<float> &row) {
+          return row.size() == vocab_size;
+        });
+
+    if (uniform && rows > 0 &&
+        vocab_size <= std::numeric_limits<size_t>::max() / rows) {
+      r->vocab_size = static_cast<int32_t>(vocab_size);
+      float *flat = new float[rows * vocab_size];
+      for (size_t i = 0; i < rows; ++i) {
+        std::copy(result.vocab_log_probs[i].begin(),
+                  result.vocab_log_probs[i].end(), flat + i * vocab_size);
+      }
+      r->vocab_log_probs = flat;
+    } else {
+      r->vocab_log_probs = nullptr;
+      r->vocab_size = 0;
+    }
   } else {
     r->vocab_log_probs = nullptr;
     r->vocab_size = 0;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sherpa-onnx/c-api/c-api.cc` around lines 868 - 879, The code flattens
result.vocab_log_probs into flat assuming every row matches vocab_size and
multiplies r->count*vocab_size without overflow checks; to fix, before
allocating and copying, validate that result.vocab_log_probs.size() ==
static_cast<size_t>(r->count), compute vocab_size from row 0, then loop over i
and verify result.vocab_log_probs[i].size() == static_cast<size_t>(vocab_size)
(handle mismatch by returning an error/cleanup), and check/mask the
multiplication r->count * vocab_size for overflow (or use size_t and
bounds-check against numeric_limits<size_t>::max()) before new float[...] to
avoid buffer overruns when filling flat and ensure safe memory allocation for
flat and correct assignment to r->vocab_size.

Comment on lines +80 to +88
if (!src.token_log_probs.empty() &&
src.token_log_probs.size() == src.tokens.size()) {
r.ys_log_probs = src.token_log_probs;
}

if (!src.vocab_log_probs.empty() &&
src.vocab_log_probs.size() == src.tokens.size()) {
r.vocab_log_probs = src.vocab_log_probs;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep log-prob arrays aligned with filtered output tokens

Convert() filters tokens before writing r.tokens, but these assignments copy arrays indexed by src.tokens. That can break the per-token alignment contract for r.ys_log_probs/r.vocab_log_probs when tokens are skipped.

💡 Suggested fix
@@
   OfflineRecognitionResult r;
   r.tokens.reserve(src.tokens.size());
   r.timestamps.reserve(src.timestamps.size());
+  std::vector<int32_t> kept_indices;
+  kept_indices.reserve(src.tokens.size());
@@
     auto sym = sym_table[src.tokens[i]];
@@
     r.tokens.push_back(std::move(sym));
+    kept_indices.push_back(i);
   }
@@
-  if (!src.token_log_probs.empty() &&
-      src.token_log_probs.size() == src.tokens.size()) {
-    r.ys_log_probs = src.token_log_probs;
+  if (!src.token_log_probs.empty() &&
+      src.token_log_probs.size() == src.tokens.size()) {
+    r.ys_log_probs.reserve(kept_indices.size());
+    for (auto idx : kept_indices) {
+      r.ys_log_probs.push_back(src.token_log_probs[idx]);
+    }
   }
 
   if (!src.vocab_log_probs.empty() &&
       src.vocab_log_probs.size() == src.tokens.size()) {
-    r.vocab_log_probs = src.vocab_log_probs;
+    r.vocab_log_probs.reserve(kept_indices.size());
+    for (auto idx : kept_indices) {
+      r.vocab_log_probs.push_back(src.vocab_log_probs[idx]);
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sherpa-onnx/csrc/offline-recognizer-ctc-impl.h` around lines 80 - 88,
Convert() is copying src.token_log_probs and src.vocab_log_probs directly into
r.ys_log_probs and r.vocab_log_probs using src.tokens indices, which breaks
alignment when Convert() filters tokens before assigning r.tokens; update the
logic in Convert() to build r.ys_log_probs and r.vocab_log_probs by iterating
the same filtered token indices used to produce r.tokens and push the
corresponding entries from src.token_log_probs and src.vocab_log_probs (only
when those source arrays are non-empty and have sufficient size), so the
resulting r.ys_log_probs and r.vocab_log_probs match r.tokens in length and
order.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the capture and exposure of vocabulary log-probabilities within the offline CTC decoder and C-API to support entropy-based confidence estimation. Key changes involve updating data structures and the greedy search decoder to record these probabilities. Feedback points out a potential integer overflow during memory allocation in the C-API and suggests optimizing memory consumption by making the full vocabulary distribution recording optional and using a flattened vector structure.

Comment on lines +875 to +880
float *flat = new float[r->count * vocab_size];
for (int32_t i = 0; i < r->count; ++i) {
std::copy(result.vocab_log_probs[i].begin(),
result.vocab_log_probs[i].end(), flat + i * vocab_size);
}
r->vocab_log_probs = flat;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Potential integer overflow in the calculation of the buffer size and pointer arithmetic. r->count and vocab_size are both int32_t, so their product is calculated as a 32-bit signed integer. If the product exceeds INT32_MAX (approx. 2.1 billion), it will overflow, leading to an incorrect allocation size and undefined behavior during indexing in the subsequent loop. This is a realistic scenario for long audio files or large vocabularies.

    float *flat = new float[static_cast<size_t>(r->count) * vocab_size];
    for (int32_t i = 0; i < r->count; ++i) {
      std::copy(result.vocab_log_probs[i].begin(),
                result.vocab_log_probs[i].end(),
                flat + static_cast<size_t>(i) * vocab_size);
    }
    r->vocab_log_probs = flat;

r.tokens.push_back(y);
r.timestamps.push_back(t);
r.token_log_probs.push_back(log_prob);
r.vocab_log_probs.emplace_back(p_log_probs, p_log_probs + vocab_size);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Unconditionally populating vocab_log_probs for every emitted token can lead to excessive memory consumption, especially for long audio inputs and models with large vocabularies. For instance, a 1-hour recording with 10,000 tokens and a 50,000-word vocabulary would require approximately 2GB of additional memory. Since this data is only needed for specific use cases like entropy-based confidence estimation, it should be optional. Additionally, using std::vector<std::vector> results in many small allocations; a single flat vector would be more efficient.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant