Skip to content

Fix chat template marker auto-detection for Zephyr's context-dependent tokenization - #899

Merged
danielhanchen merged 3 commits into
mainfrom
fix-zephyr-marker-autodetect
Jul 11, 2026
Merged

danielhanchen merged 3 commits into
mainfrom
fix-zephyr-marker-autodetect

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

What was broken

get_chat_template_parts failed on Zephyr (unsloth/zephyr-sft, HuggingFaceH4/zephyr-7b-beta) with:

ValueError: Unsloth: Could not reliably auto-detect response_part (detected '<|assistant|>\n') - pass instruction_part and response_part.

Zephyr's role tags (<|user|>, <|assistant|>) are ordinary text, not special tokens. SentencePiece tokenizes them context-dependently:

  • standalone / at text start: ▁< | ass istant | >
  • mid-conversation after </s> + newline: ▁ <0x0A> < | ass istant | > (bare <, no word-start piece)

The validation step tokenized each candidate marker standalone (plus a leading-space variant) and searched that token core in the rendered 3-turn probe, so:

  1. '<|assistant|>\n' never matched anywhere in the probe and the detector raised. The usual manual markers fail the same way at masking time (same standalone tokenization in _find_common_token_ids(force_match=True)), so Zephyr had no working path and train_on_responses_only masked every assistant token.
  2. Subtler: '<|user|>\n' DID validate, but only via its single text-start occurrence (the very first user turn). Had detection gotten that far, masking would have missed every later user turn and trained on user content after the first assistant header.

The fix

In the marker validation of get_chat_template_parts:

  • also probe a leading-newline candidate ('\n' + part), which reproduces the mid-conversation tokenization (the newline carries the SentencePiece word-start piece, leaving the tag tokenized exactly as it appears after a turn boundary), and
  • prefer candidates whose token core matches at least twice in the probe. The probe renders three turns per role, so a reliable marker must match from the second turn on; a single hit can be the text-start tokenization only, which breaks multi-turn masking.

Single-match acceptance is kept for the original two candidates, so any template that previously validated still validates, and templates whose first candidate matches repeatedly (all special-token templates) return exactly what they returned before.

Zephyr now auto-detects ('\n<|user|>\n', '\n<|assistant|>\n'), and those strings round-trip through train_on_responses_only's exact-match tokenization correctly.

Validation

Catalog sweep over the 35 Studio chat templates (real tokenizers, two-turn fixture with a system message, token-level label checks: user+system masked, all assistant turns trained, final EOS trained):

  • Zephyr: AUTO_FAILS -> auto works. user1_masked / user2_masked / system_masked / asst1_trained / asst2_trained / eos_trained all pass; trained text is exactly 'grape reply number one.</s>grape reply number two.</s> \n'.
  • Every other template row (33 common rows plus Qwen/QwQ-32B re-checked separately): detected parts, labels, verdict and notes byte-identical before vs after the change.

Tests: new tests/test_zephyr_marker_context.py builds the verbatim Zephyr template on the cached hf-internal-testing/llama-tokenizer (role tags NOT registered as special tokens, eos parsing matched to Zephyr's tokenizer) and asserts detection, token-level masking, both assistant EOS labels trained, and that special-token templates keep their previous detection. Both new regression tests fail on main and pass with the fix; test_special_token_template_unchanged passes on both.

tests/test_zephyr_marker_context.py tests/test_reasoning_marker_strip.py
tests/test_notebook_chat_templates.py tests/test_vlm_collator_masking.py
tests/test_pr684_review_fixes_a.py tests/test_mlx_autodetect_template_source.py
tests/test_mlx_trainer_internals.py tests/test_zoo_history_regressions_deep.py
tests/test_zoo_source_upstream_refs.py tests/test_extended_dep_api_pins.py
263 passed, 3 skipped

get_chat_template_parts validated candidate markers by tokenizing them
standalone and searching the token core in the rendered 3-turn probe.
Zephyr's role tags are not special tokens, and SentencePiece tokenizes
the leading < as a word-start piece standalone but as a bare piece when
the tag follows </s> plus a newline mid-conversation, so:

- response_part '<|assistant|>\n' never matched the probe and the
  detector raised, forcing users back to manual markers that fail the
  same way.
- instruction_part '<|user|>\n' validated through its single text-start
  occurrence (the first user turn only), which at masking time misses
  every later user turn and would train on user content after the first
  assistant header.

Fix: probe a leading-newline candidate as well, and prefer candidates
whose token core matches at least twice in the probe (the probe holds
three turns per role, so a reliable marker matches from the second turn
on). Single-match acceptance is kept for the original candidates so
previously passing templates keep their exact detection.

Validated over the 35-template Studio catalog with real tokenizers:
Zephyr now auto-detects ('\n<|user|>\n', '\n<|assistant|>\n') with
system and user turns masked, all assistant turns trained and the final
EOS trained; every other template's detected parts, labels and verdict
are byte-identical to before the change.

@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 improves the auto-detection of role markers (such as Zephyr's <|user|> and <|assistant|>) whose tokenization depends on context. It updates the validation logic in unsloth_zoo/dataset_utils.py to probe a leading-newline candidate and prioritize candidates that match at least twice in the multi-turn probe, preventing issues with single-context matches. A comprehensive test suite has also been added in tests/test_zephyr_marker_context.py. The review feedback suggests a minor refactoring of the is_trained helper function in the new test file to avoid semicolons and use a generator expression for better readability and PEP 8 compliance.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +101 to +103
def is_trained(sub):
i = text.index(sub); s, e = i, i + len(sub)
return all(k in un for k in [j for j, (a, b) in enumerate(offs) if b > a and a < e and b > s])

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.

medium

We can simplify the helper function is_trained to be more readable and idiomatic:

  1. Avoid using a semicolon to separate statements on a single line, adhering to PEP 8 guidelines.
  2. Simplify the list comprehension inside all(...) to a direct generator expression, which avoids creating an intermediate list in memory.
Suggested change
def is_trained(sub):
i = text.index(sub); s, e = i, i + len(sub)
return all(k in un for k in [j for j, (a, b) in enumerate(offs) if b > a and a < e and b > s])
def is_trained(sub):
i = text.index(sub)
s, e = i, i + len(sub)
return all(j in un for j, (a, b) in enumerate(offs) if b > a and a < e and b > s)
References
  1. PEP 8 discourages multiple statements on the same line separated by semicolons. (link)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Style-only suggestion with no behavior change; the helper is a five-line test-local closure and the current form matches the sibling tests in this directory. Keeping as is.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 8e80ed406c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eba55d740d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test_zephyr_marker_context.py Outdated
Comment on lines +35 to +36
from transformers import AutoTokenizer
from tokenizers import Tokenizer

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle missing tokenizer deps in the Zephyr tests

When transformers or tokenizers is not installed, these imports raise before the surrounding try block, so _setup() never reaches the intended pytest.skip path. I verified python -m pytest tests/test_zephyr_marker_context.py -q in this environment fails all three new tests with ModuleNotFoundError: No module named 'transformers'; move these optional imports inside the try or catch ImportError so dependency-light test runs skip instead of failing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in the follow-up commit: the transformers/tokenizers imports moved inside a try that returns None on ImportError, which routes into the existing pytest.skip. Verified both ways: 3 passed with deps present, 3 skipped with the imports blocked.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

The transformers/tokenizers imports ran before the try block, so
dependency-light runs errored during setup instead of skipping.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 1cff784f87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen
danielhanchen merged commit d9298b2 into main Jul 11, 2026
15 checks passed
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.

1 participant