feat: add Qwen3-8B template - #47
Conversation
| {%- else %} | ||
| {{- content }} |
There was a problem hiding this comment.
Is this the history content? If so, it is part of the training here, but I think we only want to train on the last generation and have the rest as context. So I think we want to exclude this part, maybe by doing sth like this:
{%- set assistant_body %}
…existing think/content logic, tool_calls loop, '<|im_end|>'…
{%- endset %}
{%- if loop.index0 > ns.last_query_index %}
{%- generation %}{{- assistant_body -}}{%- endgeneration %}
{%- else %}
{{- assistant_body }}
{%- endif %}There was a problem hiding this comment.
Thanks for pointing this out. Yes, the else block is the history content. I've fixed it using your using suggestion.
Qwen3 drops <think> from assistant turns at or before the last user query, so history rendered without a reasoning trace was being trained on.
Qwen3 drops <think> from assistant turns at or before the last user query, so history rendered without a reasoning trace was being trained on.
Vendors the pristine Qwen/Qwen3-8B chat_template as a fixture and diffs rendered output across the 20-combination matrix.
Done. Also added a test to ensure that messages rendered with the new template are byte-equivalent to those rendered with the upstream template.
Fair point. All Qwen3 models except |
Do we have a strong reason for this? The only explanation I can think of is that the non-final assistant turns wouldn't have the reasoning traces, so the model would be learning to produce a mix of reasoning as well as non-reasoning outputs. On the other hand, wouldn't we be losing a lot of training signal in case of multi-turn conversations? |
|
AFAIK, this is how the Qwen template handles it. |
|
Nice, this looks right to me now. I checked the rendering on the normal shapes and it does what it should. One thing worth handling before this goes in, and I think it should go into this PR. Gating the markers makes zero-span rows possible. If a conversation has no assistant turn after the last real user message, there is no generation region and the mask comes out empty. TRL checks for exactly that and raises inside
So one bad row kills tokenization for the whole mixture, before a single training step, with an error that points at the template instead of the data. It splits in two, and only half of it is about Qwen:
So the solution is part global, part template-aware:
Also worth a zero-span test – the current masking tests cover marker presence and history exclusion, which is why this didn't show up. One edge case for completeness, though I doubt it shows up in practice: a conversation ending on a What do you think? |
Yes. This will do.
I actually ran into this problem too when I was testing out this template. I also observed the edge cases that you speak of (turn ending in tool use, no assistant turn after the last user turn). The solutions you propose make sense, but I think the most robust solution would be to simply apply the chat template and filter out rows that have have empty loss masks. Otherwise, we risk an edge case that we haven't considered crashing the run at a later point. Consider: What do you think? |
|
See #48 for the full script. |
|
I see the point. Using the template itself to filter is definitely the most robust solution. I'm just worried that this will drop unconditionally: If a dataset loses 5%, we don't really know what the issue was. Could we pair the mask check with a categorised count of the failure modes we already know: no assistant turn at all, no assistant turn after the last real user query, and an "other" bucket? The mask stays the authority on keep/drop, the categories only say why, and "other" being non-zero is the useful signal, since that's a shape none of us has thought about yet. This way we can catch irregularities. Moreover, I think we could implement this inside |
Summary
Adds
qwen3-8b.jinjato the chat-template registry directory: Qwen3-8B's upstream template with{% generation %}markers spliced in so it can driveassistant_only_loss=True.The upstream template concatenates the turn header and the message body into a single emission (
'<|im_start|>' + message.role + '\n' + content), so the markers can't simply wrap the assistant branch. The header emission is split out and left outside the markers;{% generation %}opens after it and closes around<|im_end|>, with the trailing\noutside. Result: assistant content, reasoning trace and tool calls are in the loss, prompt tokens and the turn separator are not.Type of change
Validation
Rendered output compared byte-for-byte against the pristine upstream template across 20 combinations — 5 conversation shapes (single-turn reasoning, multi-turn, no-think, tool call, system+tools) ×
add_generation_prompton/off × with/withouttools— all identical, confirming the restructuring is loss-mask-only and changes no inference behaviour.