The recommended Teich training flow is trainer-first:
prepare_data()renders trainer-friendly text rows and stores Teich supervision spans.SFTTraineror your trainer setup tokenizes those rows.mask_data()converts Teich spans into labels after tokenization.
This works well with TRL and Unsloth because Teich does not need to guess token offsets before the trainer has applied its own tokenization path.
from teich import mask_data, prepare_data
train_dataset = prepare_data(
"username/my-agent-dataset",
tokenizer,
max_length=32768,
oversized_policy="trim_followups",
tokenize=True,
chat_template_kwargs={"enable_thinking": True},
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=train_dataset,
args=SFTConfig(
dataset_text_field="text",
max_length=32768,
packing=False,
output_dir="outputs",
),
)
trainer = mask_data(
trainer,
tokenizer=tokenizer,
train_on_reasoning=True,
train_on_final_answers=True,
train_on_tools=True,
)
trainer.train()Keep packing=False for this flow because packed datasets merge row boundaries before masking.
Before mask_data(), the trainer dataset usually contains:
{
"text": "...",
"teich_supervised_spans": [...],
"input_ids": [...],
"attention_mask": [...],
}After mask_data(), Teich replaces trainer datasets with:
{
"input_ids": [...],
"labels": [-100, -100, 1234, 5678, ...],
}Where:
-100means "ignore this token in loss"- non-
-100labels are the exact tokens selected by the masking policy - prompt, user, system, developer, and tool-output context stays masked by default
- assistant reasoning, final answers, and tool calls become supervised by default
When the tokenizer supports batched offset mappings, mask_data() tokenizes
each dataset-map batch together instead of issuing one tokenizer call per row.
For Qwen-style templates, the initial <think> tag is intentionally included in supervision.
mask_data() trains on these by default:
- assistant reasoning
- assistant final answers
- assistant tool calls
You can override the policy:
trainer = mask_data(
trainer,
tokenizer=tokenizer,
train_on_reasoning=True,
train_on_final_answers=True,
train_on_tools=True,
train_on_user=False,
train_on_system=False,
train_on_developer=False,
train_on_tool_responses=False,
)For native Claude Code imports, masked system context may include Claude Desktop skills, MCP instructions, hook context, permission state, date changes, and session recaps recovered from the native transcript. It stays masked unless train_on_system=True.
For long-context runs, max_supervised_tokens defaults to the trainer's max_length when available. This caps the number of trainable answer tokens per row without changing the context window.
Override it explicitly:
trainer = mask_data(
trainer,
tokenizer=tokenizer,
max_supervised_tokens=8192,
)If every row is dropped by the supervised-token cap, Teich raises instead of silently training on nothing.
import os
from unsloth import FastLanguageModel
from trl import SFTConfig, SFTTrainer
from teich import mask_data, prepare_data
MAX_SEQ_LEN = 32768
MODEL_NAME = "unsloth/Qwen3.5-0.8B"
CHAT_TEMPLATE_KWARGS = {"enable_thinking": True}
PUSH_TO_HUB_REPO_ID = "username/teich-sft-model"
HF_TOKEN = os.environ.get("HF_TOKEN") or ""
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODEL_NAME,
max_seq_length=MAX_SEQ_LEN,
load_in_4bit=False,
load_in_8bit=False,
full_finetuning=False,
)
model = FastLanguageModel.get_peft_model(
model,
r=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", "out_proj"],
lora_alpha=64,
lora_dropout=0,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=3407,
use_rslora=False,
loftq_config=None,
)
train_dataset = prepare_data(
"TeichAI/lordx64-claude-opus-4.7-max-cleaned",
tokenizer,
split="train",
max_examples=500,
chat_template_kwargs=CHAT_TEMPLATE_KWARGS,
max_length=MAX_SEQ_LEN,
oversized_policy="trim_followups",
tokenize=True,
strict=True,
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=train_dataset,
eval_dataset=None,
args=SFTConfig(
dataset_text_field="text",
dataset_num_proc=1,
max_length=MAX_SEQ_LEN,
packing=False,
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
warmup_steps=5,
num_train_epochs=1,
learning_rate=2e-4,
logging_steps=1,
optim="muon",
optim_target_modules="all-linear",
weight_decay=0.001,
lr_scheduler_type="linear",
output_dir="outputs",
seed=3407,
report_to="none",
),
)
trainer = mask_data(
trainer,
tokenizer=tokenizer,
train_on_reasoning=True,
train_on_final_answers=True,
train_on_tools=True,
)
trainer_stats = trainer.train(resume_from_checkpoint=False)
print(f"{trainer_stats.metrics['train_runtime']} seconds used for training.")
model.push_to_hub_merged(PUSH_TO_HUB_REPO_ID, tokenizer, save_method="merged_16bit", token=HF_TOKEN)For Unsloth / TRL, pass tokenize=True to prepare_data() so trainer setup treats the dataset as already tokenized and preserves Teich span metadata until mask_data() runs.
Use preview_sft_example() or the dataset preview helper attached by mask_data() to inspect supervised vs masked tokens before training.
preview = trainer.train_dataset.preview(0, tokenizer)
print(preview)This is useful for checking whether reasoning, tool calls, and final answers are being supervised as intended.