I built tinychat to learn pre-training from scratch. Tokenizer, architecture, training loop, the loss curve going down. That filled the "how do you build a language model" half of the story.
This repo is the other half: post-training. SFT, preference alignment (DPO), and vision-language fine-tuning. The Hugging Face Smol Fine-Tuning Language Models course is the curriculum; this repo is my working notebook through it.
I wanted to end the course with a published, preference-aligned small model on the HF Hub. It's tuggspeedman-ai/SmolLM3-3B-summarize-dpo-lora. The code that produced it lives here.
Units 1 (SFT), 2 (DPO), and 3 (VLM SFT) are complete. U4 is the course's "Coming Soon" slot.
| Unit | Topic | Status |
|---|---|---|
| U0 | Welcome / setup | Done |
| U1 | Supervised fine-tuning with SmolLM3 | Done; 2 published adapters |
| U2 | Preference alignment (DPO) | Done; 1 published adapter |
| U3 | Vision-language models (SmolVLM2 fine-tuning) | Done; 1 published adapter |
| U4 | Coming Soon (per the course's own placeholder) | Pending |
Four LoRA adapters on the Hugging Face Hub. The first three are fine-tuned from HuggingFaceTB/SmolLM3-3B-Base; the fourth from HuggingFaceTB/SmolVLM2-2.2B-Instruct:
smol_summarize split. Trained on an A100 80GB via HF Jobs, ~97 min, ~$4 of compute. Loss 1.03 → 0.56, eval 0.44. Built from notebooks/unit1/exercise3_sft_lora.py.notebooks/unit2/exercise2_dpo_lora.py.configs/u1_ex4_sft.yaml.HuggingFaceM4/ChartQA. LoRA on the language model only, with the SigLIP vision encoder frozen (scoped via regex to model.text_model.*, since the textbook q_proj/v_proj list would otherwise also adapt the vision tower). A10G 24GB via HF Jobs, ~79 min, ~$2 of compute. Loss 0.745 → 0.219, eval token accuracy 0.82. The visible adaptation is the answer-format shift, from verbose paragraphs to single-word answers; factual chart reading is not much better than the base model already did. Built from notebooks/unit3/exercise_vlm_sft.py.notebooks/unit1/ Hands-on exercises for each unit
exercise1_chat_templates.py Chat template internals (no GPU)
exercise2_dataset_processing.py SmolTalk2 schema + GSM8K normalization (no GPU)
exercise3_sft_lora.py The SFT training script
exercise3_sft_lora_completed.ipynb Captured session output from the local smoke run
notebooks/unit2/
exercise2_dpo_lora.py The DPO training script (preference alignment)
notebooks/unit3/
exercise_vlm_sft.py The VLM SFT training script (SmolVLM2 on ChartQA)
configs/
u1_ex4_sft.yaml TRL CLI hyperparameter config (Ex4 production rep)
.claude/
commands/ Claude Code slash commands I use for this project
rules/core.md Engineering and ML rules the codebase follows
CLAUDE.md Project-level instructions for Claude Code
Makefile Common commands (setup, smoke, hf-login)
pyproject.toml uv-managed Python project
This project uses uv as the package manager and Python 3.12.
git clone https://github.com/tuggspeedman-ai/hf-smol-course.git
cd hf-smol-course
# Install base deps
make setup
# Or with Jupyter for cell-by-cell notebook work
make setup-notebooks
# Smoke-test the env (loads SmolLM2-135M)
make smoke
# Authenticate with HF (needs an HF_TOKEN in .env or the prompt)
make hf-login
To reproduce the U1 SFT run locally on Mac (Metal/MPS, ~16 hours at 24 s/step):
uv run python notebooks/unit1/exercise3_sft_lora.py # SMOKE=True default
SMOKE=false uv run python notebooks/unit1/exercise3_sft_lora.py
To reproduce it on cloud via HF Jobs (~97 min, ~$4 on a100-large):
hf jobs uv run \
--flavor a100-large \
--timeout 3h \
--secrets HF_TOKEN \
--env SMOKE=false \
notebooks/unit1/exercise3_sft_lora.py
The U2 DPO run works the same way (~2.4h, ~$6 on a100-large). It loads the U1 SFT adapter, so that one needs to exist first:
hf jobs uv run \
--flavor a100-large \
--timeout 4h \
--secrets HF_TOKEN \
--env SMOKE=false \
notebooks/unit2/exercise2_dpo_lora.py
The U3 VLM SFT run uses a10g-large instead (cheaper, fits SmolVLM2-2.2B plus image tokens fine at batch 1; ~79 min, ~$2):
hf jobs uv run \
--flavor a10g-large \
--timeout 3h \
--secrets HF_TOKEN \
--env SMOKE=false \
notebooks/unit3/exercise_vlm_sft.py
These are a few lessons I think are worth sharing publicly:
chat_template = None. Use the instruct tokenizer for templating; the vocab is shared with the base so the embedding indices align directly.load_dataset("HuggingFaceTB/smoltalk2", "SFT", split=X) resolves the entire 66GB config before isolating a split. A bare load_dataset for a 2 MB split started downloading 7+ GB before I killed it. Fix: hf_hub_download of the exact parquet file.hub_strategy: every_save pushes every checkpoint live during training, not just at the end. Pairs with the push-irreplaceable-first rule above: that one covers end-of-run failures (timeouts, OOMs), this one covers mid-run failures (cancels, credit exhaustion). If the job dies after a checkpoint, you still have a partial artifact on the Hub instead of nothing. Discovered via the Ex4 TRL CLI config; worth wiring into custom scripts for any run over a couple of hours.hf jobs inspect; it's in the /api/jobs/<owner>/<id> response as cancelReason: NO_CREDITS. Now I top up to roughly 2x the estimated cost before submitting.The codebase has comments explaining these in context, not just as anecdotes.
hf jobs run + lighteval) is part of each unit's final-project submission, which I haven't tackled yet for either U1 or U2.openai/summarize_from_feedback) is gated behind a legacy loading script that current datasets refuses to run.SFTTrainer, DPOTrainer, and the CLI.Apache 2.0. See LICENSE.
10 commits
Python
69.8%
Jupyter Notebook
29.0%
Makefile
1.2%
I built tinychat to learn pre-training from scratch. Tokenizer, architecture, training loop, the loss curve going down. That filled the "how do you build a language model" half of the story.
This repo is the other half: post-training. SFT, preference alignment (DPO), and vision-language fine-tuning. The Hugging Face Smol Fine-Tuning Language Models course is the curriculum; this repo is my working notebook through it.
I wanted to end the course with a published, preference-aligned small model on the HF Hub. It's tuggspeedman-ai/SmolLM3-3B-summarize-dpo-lora. The code that produced it lives here.
Units 1 (SFT), 2 (DPO), and 3 (VLM SFT) are complete. U4 is the course's "Coming Soon" slot.
| Unit | Topic | Status |
|---|---|---|
| U0 | Welcome / setup | Done |
| U1 | Supervised fine-tuning with SmolLM3 | Done; 2 published adapters |
| U2 | Preference alignment (DPO) | Done; 1 published adapter |
| U3 | Vision-language models (SmolVLM2 fine-tuning) | Done; 1 published adapter |
| U4 | Coming Soon (per the course's own placeholder) | Pending |
Four LoRA adapters on the Hugging Face Hub. The first three are fine-tuned from HuggingFaceTB/SmolLM3-3B-Base; the fourth from HuggingFaceTB/SmolVLM2-2.2B-Instruct:
smol_summarize split. Trained on an A100 80GB via HF Jobs, ~97 min, ~$4 of compute. Loss 1.03 → 0.56, eval 0.44. Built from notebooks/unit1/exercise3_sft_lora.py.notebooks/unit2/exercise2_dpo_lora.py.configs/u1_ex4_sft.yaml.HuggingFaceM4/ChartQA. LoRA on the language model only, with the SigLIP vision encoder frozen (scoped via regex to model.text_model.*, since the textbook q_proj/v_proj list would otherwise also adapt the vision tower). A10G 24GB via HF Jobs, ~79 min, ~$2 of compute. Loss 0.745 → 0.219, eval token accuracy 0.82. The visible adaptation is the answer-format shift, from verbose paragraphs to single-word answers; factual chart reading is not much better than the base model already did. Built from notebooks/unit3/exercise_vlm_sft.py.notebooks/unit1/ Hands-on exercises for each unit
exercise1_chat_templates.py Chat template internals (no GPU)
exercise2_dataset_processing.py SmolTalk2 schema + GSM8K normalization (no GPU)
exercise3_sft_lora.py The SFT training script
exercise3_sft_lora_completed.ipynb Captured session output from the local smoke run
notebooks/unit2/
exercise2_dpo_lora.py The DPO training script (preference alignment)
notebooks/unit3/
exercise_vlm_sft.py The VLM SFT training script (SmolVLM2 on ChartQA)
configs/
u1_ex4_sft.yaml TRL CLI hyperparameter config (Ex4 production rep)
.claude/
commands/ Claude Code slash commands I use for this project
rules/core.md Engineering and ML rules the codebase follows
CLAUDE.md Project-level instructions for Claude Code
Makefile Common commands (setup, smoke, hf-login)
pyproject.toml uv-managed Python project
This project uses uv as the package manager and Python 3.12.
git clone https://github.com/tuggspeedman-ai/hf-smol-course.git
cd hf-smol-course
# Install base deps
make setup
# Or with Jupyter for cell-by-cell notebook work
make setup-notebooks
# Smoke-test the env (loads SmolLM2-135M)
make smoke
# Authenticate with HF (needs an HF_TOKEN in .env or the prompt)
make hf-login
To reproduce the U1 SFT run locally on Mac (Metal/MPS, ~16 hours at 24 s/step):
uv run python notebooks/unit1/exercise3_sft_lora.py # SMOKE=True default
SMOKE=false uv run python notebooks/unit1/exercise3_sft_lora.py
To reproduce it on cloud via HF Jobs (~97 min, ~$4 on a100-large):
hf jobs uv run \
--flavor a100-large \
--timeout 3h \
--secrets HF_TOKEN \
--env SMOKE=false \
notebooks/unit1/exercise3_sft_lora.py
The U2 DPO run works the same way (~2.4h, ~$6 on a100-large). It loads the U1 SFT adapter, so that one needs to exist first:
hf jobs uv run \
--flavor a100-large \
--timeout 4h \
--secrets HF_TOKEN \
--env SMOKE=false \
notebooks/unit2/exercise2_dpo_lora.py
The U3 VLM SFT run uses a10g-large instead (cheaper, fits SmolVLM2-2.2B plus image tokens fine at batch 1; ~79 min, ~$2):
hf jobs uv run \
--flavor a10g-large \
--timeout 3h \
--secrets HF_TOKEN \
--env SMOKE=false \
notebooks/unit3/exercise_vlm_sft.py
These are a few lessons I think are worth sharing publicly:
chat_template = None. Use the instruct tokenizer for templating; the vocab is shared with the base so the embedding indices align directly.load_dataset("HuggingFaceTB/smoltalk2", "SFT", split=X) resolves the entire 66GB config before isolating a split. A bare load_dataset for a 2 MB split started downloading 7+ GB before I killed it. Fix: hf_hub_download of the exact parquet file.hub_strategy: every_save pushes every checkpoint live during training, not just at the end. Pairs with the push-irreplaceable-first rule above: that one covers end-of-run failures (timeouts, OOMs), this one covers mid-run failures (cancels, credit exhaustion). If the job dies after a checkpoint, you still have a partial artifact on the Hub instead of nothing. Discovered via the Ex4 TRL CLI config; worth wiring into custom scripts for any run over a couple of hours.hf jobs inspect; it's in the /api/jobs/<owner>/<id> response as cancelReason: NO_CREDITS. Now I top up to roughly 2x the estimated cost before submitting.The codebase has comments explaining these in context, not just as anecdotes.
hf jobs run + lighteval) is part of each unit's final-project submission, which I haven't tackled yet for either U1 or U2.openai/summarize_from_feedback) is gated behind a legacy loading script that current datasets refuses to run.SFTTrainer, DPOTrainer, and the CLI.Apache 2.0. See LICENSE.
10 commits
Python
69.8%
Jupyter Notebook
29.0%
Makefile
1.2%