Course: Course 3 — LLM Fine-Tuning Masterclass Module: FTDD-04 — TRL (Transformers Reinforcement Learning) Duration: 30–40 minutes Environment: Python 3.11+. A consumer GPU (RTX 4090 / 16–24GB) OR Apple Silicon (M-series) OR free Google Colab T4. ~6GB free disk for the model + dataset.
By the end of this lab you will have:
SFTConfig + SFTTrainer on a 1B base model — and felt the trainer-as-thin-wrapper.trl sft --config ...) and confirmed the two recipes are equivalent — the same engine, two doors.This lab is deliberately a pair of runs. The point is to feel the API and the CLI as two surfaces into the same engine before you spend the rest of the ecosystem deep-dives learning what the wrappers add.
python3.11 -m venv ftdd04-env && source ftdd04-env/bin/activate
pip install -q "trl>=1.0" transformers accelerate peft torch datasets
Verify the stack and confirm the CLI is on your PATH (it ships with TRL v1.0):
python -c "import trl; print('TRL', trl.__version__)"
trl --help # should list sft, dpo, grpo, ...
If trl --help fails, ensure trl>=1.0 installed correctly and that your venv's bin/ is on $PATH.
Use a small open base — openbmb/MiniCPM5-1B — and a tiny instruction slice. The goal is a working run, not a great model.
import torch
from datasets import load_dataset
from trl import SFTConfig, SFTTrainer
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "openbmb/MiniCPM5-1B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True,
)
# A small instruction dataset; use a hosted slice so no local prep is needed.
ds = load_dataset("HuggingFaceTB/smoltalk", "all", split="train").select(range(2000))
cfg = SFTConfig(
output_dir="./out-api",
num_train_epochs=1,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
learning_rate=2e-4, # PINNED explicitly — never rely on defaults
logging_steps=10,
save_strategy="no",
bf16=False, # set True on newer GPUs; FP16 here for broad compat
report_to="none",
)
trainer = SFTTrainer(model=model, args=cfg, train_dataset=ds, processing_class=tokenizer)
trainer.train()
print("Python-API run complete.")
Record: the final training loss. Note how little code you wrote — SFTTrainer handled chat templates, prompt masking, and packing for you. This is the thin-wrapper principle: you configured, you did not reimplement the loop.
What just happened (the teaching moment): You wrote no training loop.
SFTTrainersubclassed the HuggingFaceTrainer, overrodecompute_losswith the SFT objective, and inherited the optimizer, scheduler, and device placement. That is the substrate.
Now re-run the same recipe from a YAML config, with no Python at all. Create sft.yml:
# sft.yml — the production CLI path
model_name_or_path: openbmb/MiniCPM5-1B
dataset_name: HuggingFaceTB/smoltalk
dataset_config_name: all
dataset_train_split: train
max_seq_length: 2048
output_dir: ./out-cli
num_train_epochs: 1
per_device_train_batch_size: 2
gradient_accumulation_steps: 4
learning_rate: 2e-4
logging_steps: 10
save_strategy: "no"
bf16: false
report_to: none
Run it:
trl sft --config sft.yml
Record: the final training loss from the CLI run.
Compare: the two losses should be effectively identical (same model, same data slice, same hyperparameters, same underlying
SFTTrainer). Any small difference is RNG seed / data-order nondeterminism, not a difference in method. You have now felt the API and the CLI as two doors into the same engine.
No code. Answer in 3–5 sentences:
sft.yml keeps working across TRL 1.x releases. What is the ONE thing it does not guarantee that you must defend against in production?Confirm the config-key stability promise is real — list the keys the CLI accepts, and confirm the ones you used are documented:
trl sft --help # lists every CLI flag, which mirrors the YAML keys
python -c "from trl import SFTConfig; import dataclasses; print([f.name for f in dataclasses.fields(SFTConfig)])"
Skim the output. You should see model_name_or_path, learning_rate, per_device_train_batch_size, lora_r, deepspeed, and dozens more. These are the stable surface the Stability Contract protects.
Submit ftdd04-lab-report.md:
SFTTrainer handled for you (chat template, prompt masking, packing).trl sft --help.SFTTrainer handled the chat template, prompt masking, and sequence packing — the ergonomics that hand-rolled SFT loses hours on.learning_rate, batch_size, gradient_accumulation_steps) or used a different data slice. Both runs call the same SFTTrainer.model_name_or_path, learning_rate, per_device_train_batch_size, lora_r, deepspeed, num_train_epochs, bf16, etc.dpo.yml over a small preference dataset (e.g., trl-lib/ultrafeedback_binarized) and run trl dpo --config dpo.yml. Observe that the CLI surface is uniform across trainers — the same ergonomics, a different objective. (Sets up FTDD-05 / Axolotl.)use_peft: true, lora_r: 8, lora_alpha: 16 to sft.yml and re-run. Confirm the steerable-params percentage is under 1% — the Steering Stack thesis (FT00), felt through TRL. (Sets up FT08.)# Lab Specification — Module FTDD-04: TRL
**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FTDD-04 — TRL (Transformers Reinforcement Learning)
**Duration**: 30–40 minutes
**Environment**: Python 3.11+. A consumer GPU (RTX 4090 / 16–24GB) OR Apple Silicon (M-series) OR free Google Colab T4. ~6GB free disk for the model + dataset.
---
## Learning objectives
By the end of this lab you will have:
1. **Run an SFT job via the TRL Python API** — `SFTConfig` + `SFTTrainer` on a 1B base model — and felt the trainer-as-thin-wrapper.
2. **Re-run the identical job via the production CLI** (`trl sft --config ...`) and confirmed the two recipes are equivalent — the same engine, two doors.
3. **Inspected the YAML config schema** and understood why a config file (not training code) is the production path: reproducibility-as-a-file, CI-validatable.
4. **Stated, in your own words, why TRL is the substrate** — and what dropping to the Python API buys you that the CLI cannot.
This lab is deliberately a *pair* of runs. The point is to feel the API and the CLI as two surfaces into the same engine before you spend the rest of the ecosystem deep-dives learning what the wrappers add.
---
## Phase 0 — Environment setup (5 min)
```bash
python3.11 -m venv ftdd04-env && source ftdd04-env/bin/activate
pip install -q "trl>=1.0" transformers accelerate peft torch datasets
```
Verify the stack and confirm the CLI is on your PATH (it ships with TRL v1.0):
```bash
python -c "import trl; print('TRL', trl.__version__)"
trl --help # should list sft, dpo, grpo, ...
```
If `trl --help` fails, ensure `trl>=1.0` installed correctly and that your venv's `bin/` is on `$PATH`.
---
## Phase 1 — SFT via the Python API (10 min)
Use a small open base — `openbmb/MiniCPM5-1B` — and a tiny instruction slice. The goal is a *working run*, not a great model.
```python
import torch
from datasets import load_dataset
from trl import SFTConfig, SFTTrainer
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "openbmb/MiniCPM5-1B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True,
)
# A small instruction dataset; use a hosted slice so no local prep is needed.
ds = load_dataset("HuggingFaceTB/smoltalk", "all", split="train").select(range(2000))
cfg = SFTConfig(
output_dir="./out-api",
num_train_epochs=1,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
learning_rate=2e-4, # PINNED explicitly — never rely on defaults
logging_steps=10,
save_strategy="no",
bf16=False, # set True on newer GPUs; FP16 here for broad compat
report_to="none",
)
trainer = SFTTrainer(model=model, args=cfg, train_dataset=ds, processing_class=tokenizer)
trainer.train()
print("Python-API run complete.")
```
**Record**: the final training loss. Note how little code you wrote — `SFTTrainer` handled chat templates, prompt masking, and packing for you. This is the thin-wrapper principle: you configured, you did not reimplement the loop.
> **What just happened (the teaching moment):** You wrote no training loop. `SFTTrainer` subclassed the HuggingFace `Trainer`, overrode `compute_loss` with the SFT objective, and inherited the optimizer, scheduler, and device placement. That is the substrate.
---
## Phase 2 — The identical job via the production CLI (10 min)
Now re-run the *same recipe* from a YAML config, with no Python at all. Create `sft.yml`:
```yaml
# sft.yml — the production CLI path
model_name_or_path: openbmb/MiniCPM5-1B
dataset_name: HuggingFaceTB/smoltalk
dataset_config_name: all
dataset_train_split: train
max_seq_length: 2048
output_dir: ./out-cli
num_train_epochs: 1
per_device_train_batch_size: 2
gradient_accumulation_steps: 4
learning_rate: 2e-4
logging_steps: 10
save_strategy: "no"
bf16: false
report_to: none
```
Run it:
```bash
trl sft --config sft.yml
```
**Record**: the final training loss from the CLI run.
> **Compare:** the two losses should be effectively identical (same model, same data slice, same hyperparameters, same underlying `SFTTrainer`). Any small difference is RNG seed / data-order nondeterminism, not a difference in method. You have now felt the API and the CLI as two doors into the same engine.
---
## Phase 3 — Why the CLI wins in production (5 min)
No code. Answer in 3–5 sentences:
1. The YAML in Phase 2 is your entire training recipe. List three things about it that make it more reproducible / CI-friendly than the Python script in Phase 1.
2. What is the ONE thing in Phase 1 that you *cannot* do from a static YAML in Phase 2? (Hint: think about GRPO with a custom reward function.) When would that force you back to the Python API?
3. The Stability Contract guarantees your `sft.yml` keeps working across TRL 1.x releases. What is the ONE thing it does *not* guarantee that you must defend against in production?
---
## Phase 4 — Inspect the config schema (optional, 5 min)
Confirm the config-key stability promise is real — list the keys the CLI accepts, and confirm the ones you used are documented:
```bash
trl sft --help # lists every CLI flag, which mirrors the YAML keys
python -c "from trl import SFTConfig; import dataclasses; print([f.name for f in dataclasses.fields(SFTConfig)])"
```
Skim the output. You should see `model_name_or_path`, `learning_rate`, `per_device_train_batch_size`, `lora_r`, `deepspeed`, and dozens more. These are the stable surface the Stability Contract protects.
---
## Deliverables
Submit `ftdd04-lab-report.md`:
- [ ] Phase 1: the Python-API run's final training loss; a one-line note on what `SFTTrainer` handled for you (chat template, prompt masking, packing).
- [ ] Phase 2: the CLI run's final training loss; a note confirming it matches Phase 1 within RNG noise.
- [ ] Phase 3: your 3–5 sentence answers to the three questions.
- [ ] (Optional) Phase 4: three config keys you found via `trl sft --help`.
---
## Solution key
- **Phase 1**: a successful run produces a decreasing loss curve and a final loss in a sane range (exact value depends on the data slice; the point is that it *runs* and *decreases*). The teaching note should mention that `SFTTrainer` handled the chat template, prompt masking, and sequence packing — the ergonomics that hand-rolled SFT loses hours on.
- **Phase 2**: the CLI run's final loss should match Phase 1 within small RNG/data-order noise. If the losses diverge materially, the student changed a hyperparameter between runs (check `learning_rate`, `batch_size`, `gradient_accumulation_steps`) or used a different data slice. Both runs call the same `SFTTrainer`.
- **Phase 3** (model answers):
1. Reproducibility/CI: (a) the YAML is config not code — no imports/dependency drift; (b) it is one diff-able, version-able file capturing the whole recipe; (c) it can be linted/schema-validated in CI before touching a GPU.
2. A GRPO job with a *custom* reward function (anything beyond a built-in) cannot be expressed in a static YAML — the reward is Python code. That forces a drop to the Python API. Same for custom data transforms or interleaved eval.
3. The Stability Contract does NOT freeze DEFAULTS. A default LR or scheduler may improve between minor versions. Pin every hyperparameter explicitly; never ride an unspecified default.
- **Phase 4**: any three of `model_name_or_path`, `learning_rate`, `per_device_train_batch_size`, `lora_r`, `deepspeed`, `num_train_epochs`, `bf16`, etc.
---
## Stretch goals
1. **Swap to DPO via the CLI.** Write a `dpo.yml` over a small preference dataset (e.g., `trl-lib/ultrafeedback_binarized`) and run `trl dpo --config dpo.yml`. Observe that the CLI surface is uniform across trainers — the same ergonomics, a different objective. (Sets up FTDD-05 / Axolotl.)
2. **Enable LoRA via the config.** Add `use_peft: true`, `lora_r: 8`, `lora_alpha: 16` to `sft.yml` and re-run. Confirm the steerable-params percentage is under 1% — the Steering Stack thesis (FT00), felt through TRL. (Sets up FT08.)
3. **Compare against Unsloth.** Re-run the Phase 1 SFT job using Unsloth's TRL-compatible API and measure throughput (tokens/sec or steps/sec). Quantify the single-GPU speedup that motivates FTDD-03.