Skip to content

SER silent-failure guards: Phases 1-7 (loud-fail guard + encoder registry + problem_type + Phase 2 warning + diagnostics CLI)#514

Merged
satra merged 7 commits into
fix/ser-wav2vec2-regression-headfrom
claude/ser-loading-guards
May 10, 2026
Merged

SER silent-failure guards: Phases 1-7 (loud-fail guard + encoder registry + problem_type + Phase 2 warning + diagnostics CLI)#514
satra merged 7 commits into
fix/ser-wav2vec2-regression-headfrom
claude/ser-loading-guards

Conversation

@satra

@satra satra commented May 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements all phases of specs/20260509-204500-ser-silent-failure-guards/plan.md. Targets PR #511's branch (fix/ser-wav2vec2-regression-head) so it stacks on the in-flight work.

Two reviewer passes: a quick "ship it" pass that landed Phases 1–7, then a strict adversarial pass against the user's "zero tolerance for silent failures" criterion that found 5 critical defects and forced changes to the routing/guard semantics. The current state addresses all 5.

Commits

  1. 1ce3453 Phase 1 — loud-fail post-load assertion (missing_keys + mismatched_keys + final-layer out_features shape sanity).
  2. 11d5800 Phases 2–7 — registry refactor, problem_type detection, standard-pipeline warning, doc + CLI.
  3. 9b92b72 First-pass reviewer cleanups — explicit encoder class names in _BASE_REGISTRY, CLI null-config note.
  4. 0849f3d mypy --extra-checks annotation fix from CI.
  5. bb4de3b Strict reviewer second pass — 5 silent-failure surfaces closed:
    • D1: dropped wav2vec2-bert from _BASE_REGISTRY. It uses log-mel input_features via SeamlessM4TFeatureExtractor, not raw input_values. Re-introducing requires a per-family preprocessing adapter.
    • D2: Phase-1 guard now treats both classifier.* and {encoder_attr}.* missing/mismatched keys as suspect (with a small whitelist for HF post_init buffers). A wrong _BASE_REGISTRY entry now fails loudly instead of silently scrambling encoder weights.
    • D3: _resolve_apply_softmax now peeks architectures when problem_type is absent. Any model declaring Wav2Vec2ForSpeechClassification (audeering signature) is treated as regression — closes the silent-corruption window where a regression checkpoint with English-emotion labels would have been softmaxed.
    • D4: _peek_head_final_layer now raises when classifier.dense.weight is present but no recognized final-layer attribute matches, instead of returning None and letting the standard pipeline silently random-init the head. Error message lists the actual classifier keys to guide adding to _FINAL_LAYER_NAMES/_KNOWN_HEAD_LAYOUTS.
    • D5: SENSELAB_STRICT_HEAD_LOAD default inverted to =1 (raise). Warn-only let downstream consumers like windowed segmentation aggregate corrupted scores. Set SENSELAB_STRICT_HEAD_LOAD=0 to demote to warning for intentional partial loads (fine-tuning warm-start).
    • T203: 3 unit tests for _check_head_loaded_cleanly cover strict-by-default raise, warn-only mode, and silent pass on clean loads.

What changed (by phase)

Phase 1 — Custom-path loud-fail (api.py)

After EmotionModel.from_pretrained(..., output_loading_info=True):

  • Raise on any suspect missing/mismatched key under classifier. or {encoder_attr}. (the Phase-1 guard updated in commit bb4de3b per D2).
  • Raise if final layer's out_featuresconfig.num_labels.

Phase 2 — Standard-pipeline guard (huggingface.py)

  • _get_hf_audio_classification_pipeline constructs AutoModelForAudioClassification.from_pretrained(..., output_loading_info=True) and threads the loaded instance into pipeline(model=..., feature_extractor=...). No double download.
  • _check_head_loaded_cleanly raises by default on suspect head keys (classifier., head., score., out_proj., projector.).
  • SENSELAB_STRICT_HEAD_LOAD=0 demotes to a warning.

Phase 3 — Encoder-family generalization (api.py)

  • _BASE_REGISTRY: dict[str, tuple[str, str, str]] covers wav2vec2/hubert/wavlm (wav2vec2-bert dropped per D1). Class names explicit; resolved lazily.
  • _make_emotion_model_class(model_type, head) uses the right base class + encoder attribute name.
  • _emotion_head_kind returns (model_type, _HeadEntry).

Phase 4 — Head registry (api.py)

  • _HeadEntry dataclass: (final_layer, activation, dropout_field).
  • _KNOWN_HEAD_LAYOUTS: dict[str, _HeadEntry] — currently the ehcalabres single-bin entry.
  • _make_regression_head_class(head) builds the matching head; _ACTIVATIONS table covers tanh/gelu/relu.

Phase 5 — problem_type detection (api.py)

  • _resolve_apply_softmax(model, ser_type) prefers config.problem_type; falls back to architecture peek (ForSpeechClassification → regression) per D3; finally to keyword heuristic.

Phase 6 — Resampling (no code change)

Verified classify_audios_with_transformers already resampled in its audio loop.

Phase 7 — Docs + diagnostic CLI

  • doc.md enumerates dispatch paths and points users at the CLI.
  • python -m senselab.audio.tasks.classification.speech_emotion_recognition --probe <repo_id> reports model_type, architectures, dispatch decision, head source, and emits advisories. No weights loaded.

Caveats this PR explicitly does NOT solve

  • wav2vec2-bert intentionally not registered (see D1).
  • Encoder forward signature drift for batched inference (T305): single-clip path is verified for wav2vec2/hubert/wavlm; batched HuBERT/WavLM may need attention_mask threading. Phase-1 guard catches missing weights, not forward-signature mismatches.
  • Multi-task heads (assumption switching to senselab and pydra #9): a checkpoint with both regression and classification branches under one classifier whose problem_type covers only one branch is not handled.
  • Custom preprocessor configs (assumption Add speaker diarization #11): we use Wav2Vec2FeatureExtractor.from_pretrained defaults; do_normalize/return_attention_mask/pre-emphasis overrides in preprocessor_config.json are silently ignored.

Backward compatibility

Legacy aliases retained as 2-line trampolines so external monkeypatching keeps working:

  • _make_wav2vec2_emotion_model_class(final_layer)
  • _wav2vec2_emotion_head_kind(model) returning just the final-layer name
  • _peek_wav2vec2_head_final_layer(model)
  • _classify_wav2vec2_speech_cls_ser(..., final_layer="out_proj")

Test plan

  • uv run pytest src/tests/audio/tasks/classification_test.py -q — all pass (1 GPU-skipped).
    • test_speech_emotion_recognition_continuous (audeering happy path).
    • test_wav2vec2_speech_cls_ser_raises_on_random_head (parametrised: missing + mismatched).
    • test_wav2vec2_speech_cls_ser_raises_on_shape_mismatch (final-layer out_features sanity).
    • test_check_head_loaded_cleanly_raises_strict_by_default (parametrised across head prefixes).
    • test_check_head_loaded_cleanly_warns_when_lax (env-var demotion).
    • test_check_head_loaded_cleanly_silent_on_clean_load (encoder-buffer whitelist).
  • uv run ruff check + uv run ruff format --check clean.
  • uv run mypy --ignore-missing-imports --extra-checks clean.
  • CLI smoke-tested against audeering, ehcalabres, superb.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66

…ation plan

Phase 1 of specs/20260509-204500-ser-silent-failure-guards/plan.md.

The custom Wav2Vec2 emotion-head path in `_classify_wav2vec2_speech_cls_ser`
trusts the dispatcher peek to have correctly matched the checkpoint's head
layout. When that assumption is wrong (new encoder family, unrecognized
final-layer attribute name, or single-bin checkpoint not in the registry)
transformers silently random-initializes the head and inference returns
~uniform softmax — the exact bug PR #511 is fixing, just one routing branch
upstream.

Convert that to a loud `RuntimeError`. After `from_pretrained` returns we
inspect `loading_info` for any `classifier.*` entry in `missing_keys`
(weights absent, would be randomly initialized) **or** `mismatched_keys`
(weights present but wrong shape — equally a silent-random failure that
the original missing-keys check would miss; flagged by architect review).
We also assert the final layer's `out_features` equals
`config.num_labels` / `len(id2label)`, catching the case where head and
config disagree on the number of classes.

Adds a parametrised unit test that mocks `EmotionModel.from_pretrained`
to return each broken-load shape and asserts the diagnostic raises with
a matching message.

Also commits the full remediation plan (specs/.../plan.md) enumerating
Phases 2-7 (standard-pipeline missing-key warning, encoder-family
generalization for HuBERT/WavLM, head-registry richening, problem_type
based discrete/continuous detection, resampling in the standard pipeline,
diagnostics CLI). Plan was self-audited and reviewed by the Plan agent
before this commit; its recommendations are folded in (mismatched_keys
coverage promoted into Phase 1, dynamic HeadSpec cut from Phase 4 as
premature, encoder-attribute-name registry promoted from a Phase 3
footnote to a load-bearing task).

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra temporarily deployed to docs-preview May 9, 2026 22:22 — with GitHub Actions Inactive

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

Copy link
Copy Markdown
Contributor

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 Phase 1 of a plan to eliminate silent-failure modes in speech emotion recognition by adding a plan document and implementing post-load assertions for classifier keys and output shapes. New unit tests verify these safeguards. Review feedback suggests optimizing a list comprehension with the walrus operator and updating the plan document to mark the unit testing task as completed.

Comment thread specs/20260509-204500-ser-silent-failure-guards/plan.md Outdated
Implements the remaining phases from
specs/20260509-204500-ser-silent-failure-guards/plan.md, layered on top
of Phase 1's loud-fail guard:

- Phase 4 (head registry): introduce ``_HeadEntry`` dataclass holding
  ``final_layer`` / ``activation`` / ``dropout_field``; promote
  ``_KNOWN_WAV2VEC2_EMOTION_HEADS`` (str→str) to ``_KNOWN_HEAD_LAYOUTS``
  (str→_HeadEntry). Generalize ``_make_wav2vec2_regression_head_class``
  to ``_make_regression_head_class(head: _HeadEntry)`` with a small
  ``_ACTIVATIONS`` table. Cache key now includes activation/dropout_field
  so future entries don't collide.

- Phase 3 (encoder-family generalization): ``_BASE_REGISTRY`` maps
  ``config.model_type`` → ``(PreTrainedModel base name, encoder attr
  name)`` for wav2vec2/hubert/wavlm/wav2vec2-bert. Resolved lazily so
  imports only fire when the custom-head path runs. ``_emotion_head_kind``
  now returns ``(model_type, _HeadEntry)`` tuples; ``_make_emotion_model_class``
  threads the right encoder class and attribute name. The Phase-1 guard
  catches mis-registered families: any wrong attr-name will surface as
  "every classifier.* key missing" with a clear ``RuntimeError``.

- Phase 5 (problem_type detection): ``_resolve_apply_softmax`` prefers
  ``config.problem_type`` over the keyword-based label heuristic. A
  regression head with non-AVD axes (e.g. ``["energy","pleasantness"]``)
  no longer gets softmax misapplied. Falls back to ``_get_ser_type`` for
  legacy checkpoints that omit ``problem_type``.

- Phase 2 (standard-pipeline missing-key warning):
  ``_get_hf_audio_classification_pipeline`` now loads the model
  explicitly via ``AutoModelForAudioClassification.from_pretrained(...,
  output_loading_info=True)`` and threads the loaded instance into
  ``pipeline(model=..., feature_extractor=...)``. ``_check_head_loaded_cleanly``
  warns on suspect missing/mismatched head keys
  (``classifier.``/``head.``/``score.``/``out_proj.``/``projector.``).
  ``SENSELAB_STRICT_HEAD_LOAD=1`` promotes the warning to a hard error.

- Phase 6 (resampling in standard pipeline): verified already implemented
  at ``classify_audios_with_transformers`` lines 141-142; no code change
  required. Plan updated to reflect the discovery.

- Phase 7 (docs + diagnostics CLI):
  - ``doc.md`` enumerates the three dispatch paths (custom in-process
    head / subprocess venv / standard pipeline) and points at the CLI.
  - New ``python -m
    senselab.audio.tasks.classification.speech_emotion_recognition
    --probe <repo_id>`` reports model_type, architectures, dispatch
    decision, head source, and emits advisories. Pure config / manifest
    inspection — no weights loaded.

Backwards-compat shims kept in place:
- ``_make_wav2vec2_emotion_model_class(final_layer)`` (delegates to
  ``_make_emotion_model_class("wav2vec2", _HeadEntry(final_layer=...))``)
- ``_wav2vec2_emotion_head_kind`` (returns just the final-layer name from
  the new ``_emotion_head_kind`` tuple).
- ``final_layer=`` kwarg on ``_classify_wav2vec2_speech_cls_ser``
  short-circuits to ``_HeadEntry(final_layer=...)``.

Verified locally: all 8 classification tests pass (1 GPU-skip), audeering
happy path passes, ehcalabres CLI probe correctly identifies its head
from ``_KNOWN_HEAD_LAYOUTS`` registry, audeering CLI probe peeks the
manifest, superb correctly routes to the standard pipeline with an
advisory.

Test coverage gaps (deferred per plan):
- Phase 2 unit test for ``_check_head_loaded_cleanly`` (covered
  end-to-end by audeering/ehcalabres tests for now).
- Phase 5 unit test for ``_resolve_apply_softmax`` (no fixture model
  with problem_type=regression + confounding labels).
- Phase 4 numerical-regression test on existing checkpoints.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra temporarily deployed to docs-preview May 9, 2026 22:40 — with GitHub Actions Inactive
Two small fixes from the second-pass architect review:

1. Make ``_BASE_REGISTRY`` a 3-tuple ``(base_pretrained_cls_name,
   encoder_model_cls_name, encoder_attr_name)`` instead of deriving the
   encoder class name via ``base_name.replace("PreTrainedModel", "Model")``.
   The string-mutation worked for current Wav2Vec2/HuBERT/WavLM/W2V-BERT
   but would silently break the day a family ships an encoder with a
   different naming convention. Explicit > clever.

   Updated both indexers (``_emotion_head_kind`` in api.py and the
   probe CLI's status print) to read attr name from index ``[2]``.

2. Probe CLI: when ``AutoConfig.from_pretrained`` fails (e.g. audeering's
   ``vocab_size: null`` tripping the strict validator), make the
   diagnostic note explicit that the dispatcher's raw-config-dict
   fallback handles this — the routing decision and advisories that
   follow still work correctly because ``getattr(None, ...)`` short-
   circuits the ``config.model_type`` checks.

Verified: existing guard tests pass; ``uv run python -m
senselab.audio.tasks.classification.speech_emotion_recognition --probe
audeering/wav2vec2-large-robust-12-ft-emotion-msp-dim`` still produces a
correct routing report end-to-end.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra temporarily deployed to docs-preview May 9, 2026 22:43 — with GitHub Actions Inactive
@satra satra changed the title Loud-fail guard for random/mismatched Wav2Vec2 emotion heads + remediation plan SER silent-failure guards: Phases 1-7 (loud-fail guard + encoder registry + problem_type + Phase 2 warning + diagnostics CLI) May 9, 2026
CI pre-commit hook runs mypy with --extra-checks which flagged the
inferred type for ``model = HFModel(...)`` in __main__.py. Add the
explicit annotation. cpu-tests already pass; this is the only blocker.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra temporarily deployed to docs-preview May 10, 2026 04:18 — with GitHub Actions Inactive
…2 unit tests

The strict reviewer identified 5 critical defects against the user's
"zero tolerance for silent failures" criterion. All addressed in this
commit:

D1. ``wav2vec2-bert`` removed from ``_BASE_REGISTRY``.
    The family consumes log-mel ``input_features`` (via
    ``SeamlessM4TFeatureExtractor``) rather than raw ``input_values``.
    The loader hardcodes the latter and accesses ``inputs["input_values"]``
    so any wav2vec2-bert checkpoint would have produced ``KeyError`` at
    best, silent garbage at worst. Add a per-family preprocessing
    adapter before re-introducing.

D2. Phase-1 guard now covers encoder-key misses.
    Previous filter was ``classifier.*`` only, which meant a wrong
    ``_BASE_REGISTRY`` entry (wrong encoder attribute name) would leave
    every encoder weight missing yet pass the guard — silent garbage
    output. The guard now treats any missing key under either
    ``classifier.`` or ``{encoder_attr}.`` as suspect, with a small
    whitelist of HF post_init buffers (``masked_spec_embed``).

D3. ``_resolve_apply_softmax`` peeks ``architectures`` on
    ``problem_type``-absent checkpoints. Any model declaring
    ``Wav2Vec2ForSpeechClassification`` (audeering signature) is treated
    as regression. Reduces the silent-corruption window for regression
    heads with English-emotion-word labels.

D4. ``_peek_head_final_layer`` raises when ``classifier.dense.weight``
    is present but no recognized final-layer attribute matches.
    Previously returned None and let the standard pipeline silently
    random-initialize the head. The error message lists the actual
    classifier keys so a maintainer can extend ``_FINAL_LAYER_NAMES``
    or ``_KNOWN_HEAD_LAYOUTS``.

D5. ``SENSELAB_STRICT_HEAD_LOAD`` default inverted to ``=1`` (raise).
    Warn-only meant downstream consumers like
    ``classify_audios`` windowed segmentation kept aggregating
    corrupted scores. Set ``SENSELAB_STRICT_HEAD_LOAD=0`` to demote to
    a warning if you intentionally want a partial-head load (e.g.
    fine-tuning warm-start).

T203. Three unit tests for ``_check_head_loaded_cleanly``: parametrised
    raise on suspect missing/mismatched-shape keys (default strict),
    warn-only mode under ``SENSELAB_STRICT_HEAD_LOAD=0``, and silent
    pass on clean loads (encoder-buffer misses don't trip).

Plan checkboxes T203 / T304 / T502 promoted from deferred-shortcut to
actually-shipped. Audeering happy-path test still passes; mypy clean
with ``--extra-checks``.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra temporarily deployed to docs-preview May 10, 2026 04:35 — with GitHub Actions Inactive
Reviewer round 3 verified D1-D5 + T203 fixes are functionally correct,
but flagged three pieces of user-facing text that still contradict the
new behavior — themselves a silent-assumption violation per the user's
"no silent failures and assumptions beneath those" criterion:

* ``doc.md`` listed ``wav2vec2-bert`` as a supported encoder for the
  custom-head path (it isn't; D1 dropped it from ``_BASE_REGISTRY``)
  and described ``SENSELAB_STRICT_HEAD_LOAD=1`` as a way to "promote
  the warning to a RuntimeError" — but ``=1`` is now the default
  (D5). A user who reads the doc, doesn't set the env var, and
  expects warn-mode would be surprised by a hard fail.

* ``__main__.py`` probe CLI advisory had the same inverted message
  about the env var.

* ``api.py:503`` dispatcher comment + ``api.py:144`` encoder-attribute
  comment still mentioned ``wav2vec2-bert``.

This commit:

1. Removes ``wav2vec2-bert`` from the doc's supported-encoder list,
   adds an explicit "intentionally not supported" sentence with the
   ``SeamlessM4TFeatureExtractor`` reason, so users know why and what
   it would take to add.
2. Inverts the env-var description in both the doc and the CLI to
   match the strict-by-default semantics.
3. Drops stale ``wav2vec2-bert`` mentions from the api.py comments.

No code-path or behavior change. Verified clean: ruff, ruff format,
mypy --extra-checks all pass.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra temporarily deployed to docs-preview May 10, 2026 04:55 — with GitHub Actions Inactive
When the strict reviewer's second pass forced T203 and T502 from
deferred-shortcut to actually-shipped, I appended the new ``[x]`` text
and kept the original ``[ ]`` lines tagged "legacy text, kept for
traceability". On re-read those duplicates just made the plan look
like there were unfinished items when there weren't. Drop the
duplicates; the ``[x]`` entries are sufficient.

Remaining ``[ ]`` items after cleanup:
  T305 — attention-mask shim for batched HuBERT/WavLM (don't optimize for unknowns)
  T403 — numerical regression test against fixed scores (Phase-1 guard + bounded-score covers)
  T602 — sampling-rate test (Phase 6 was a no-change phase, end-to-end coverage exists)

All three are honest deferrals, not shortcuts.

https://claude.ai/code/session_01DyXMQNsF7F5Ze8j3GGVw66
@satra
satra merged commit ca4024e into fix/ser-wav2vec2-regression-head May 10, 2026
9 checks passed
github-actions Bot added a commit that referenced this pull request May 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants