Lab Specification — Module FTDD-07: DeepSeek-R1

Course: Course 3 — LLM Fine-Tuning Masterclass Module: FTDD-07 — DeepSeek-R1 Duration: ~45 minutes Environment: Python 3.11+. A consumer GPU (RTX 4090 / 16–24GB) OR Apple Silicon (M-series, 16GB+ unified) OR free Google Colab T4. ~5GB free disk.


Learning objectives

By the end of this lab you will have:

  1. Loaded R1-Distill-Qwen-1.5B and observed its structured <think> chain-of-thought on a math problem — felt the distilled reasoning behavior.
  2. Compared it against a non-distilled Qwen-1.5B base on the same problem — witnessed what the distillation transferred (the chain) and what it did not (new capability — the base flails because it was never steered to deliberate).
  3. Measured the chain-of-thought structure — token length, presence of self-correction, presence of a verifiable final answer — and stated, in your own words, what the distillation installed.
  4. Connected the result to the thesis: the distilled model inherits the behavior of reasoning (the trace shape), not new ability — which is why SFT-only distillation works at all.

This lab is about observing transferred reasoning, not training. You witness what 800K curated traces install in a 1.5B student.


Phase 0 — Environment setup (5 min)

python3.11 -m venv ftdd07-env && source ftdd07-env/bin/activate
pip install -q transformers accelerate torch
# On Apple Silicon, torch uses MPS automatically. On CUDA, it uses CUDA.

Verify the stack:

import torch
print(f"PyTorch: {torch.__version__}")
print(f"MPS available: {torch.backends.mps.is_available()}")
print(f"CUDA available: {torch.cuda.is_available()}")

The 1.5B model in FP16 needs ~3GB VRAM. CPU works but is slow (~3–8 tok/s); MPS/CUDA is comfortable.


Phase 1 — Load R1-Distill-Qwen-1.5B (the student) (8 min)

from transformers import AutoModelForCausalLM, AutoTokenizer

STUDENT_ID = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"  # the distilled student

tokenizer = AutoTokenizer.from_pretrained(STUDENT_ID)
student = AutoModelForCausalLM.from_pretrained(
    STUDENT_ID,
    torch_dtype=torch.float16,
    device_map="auto",
)
student.eval()

def generate(model, prompt, max_new_tokens=1024):
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, temperature=1.0)
    return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

# A problem that rewards deliberation
MATH_PROBLEM = "A train travels 60 km in 45 minutes. What is its average speed in km/h? Show your reasoning."

print("=== R1-DISTILL-QWEN-1.5B (the distilled student) ===")
student_output = generate(student, MATH_PROBLEM)
print(student_output)

Record: the full output. Look specifically for: (a) a <think> block or visible chain-of-thought, (b) self-correction ("wait," "let me check"), (c) a clearly stated final answer with units.

What just happened (the teaching moment): This 1.5B model produces a structured reasoning chain. It deliberates. It was never trained with RL — it inherited this behavior via SFT on R1's curated traces. The behavior was transferred; the 1.5B base always had latent capability. The traces steered it to the surface.


Phase 2 — Load a non-distilled Qwen-1.5B base for comparison (8 min)

BASE_ID = "Qwen/Qwen2.5-1.5B"  # a non-distilled instruction-tuned Qwen of the same size

base_tokenizer = AutoTokenizer.from_pretrained(BASE_ID)
base = AutoModelForCausalLM.from_pretrained(
    BASE_ID,
    torch_dtype=torch.float16,
    device_map="auto",
)
base.eval()

def generate_base(model, prompt, max_new_tokens=512):
    messages = [{"role": "user", "content": prompt}]
    text = base_tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = base_tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
    return base_tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

print("=== QWEN2.5-1.5B (non-distilled, same size) ===")
base_output = generate_base(base, MATH_PROBLEM)
print(base_output)

Record: the base's output. It will typically answer more directly, with shorter or no visible deliberation, and may get the arithmetic wrong (45 minutes = 0.75 hours; 60/0.75 = 80 km/h — a common error is treating 45 min as 0.45 h).


Phase 3 — Measure what the distillation installed (8 min)

def analyze_chain(text):
    has_think = "<think>" in text or text.strip().startswith("Think") or "**reasoning**" in text.lower()
    has_self_correct = any(w in text.lower() for w in ["wait", "let me", "actually", "reconsider", "check"])
    length = len(text.split())  # rough token proxy
    # crude final-answer extraction
    lines = [l.strip() for l in text.split("\n") if l.strip()]
    final_line = lines[-1] if lines else ""
    return {
        "visible_chain": has_think,
        "self_correction": has_self_correct,
        "word_count": length,
        "final_line": final_line[:120],
    }

print("=== STUDENT (distilled) ===")
print(analyze_chain(student_output))
print("\n=== BASE (non-distilled) ===")
print(analyze_chain(base_output))

Record: both analyses side by side. Expect the distilled student to show: longer output, self-correction markers, a more explicit final answer. The non-distilled base will typically be shorter and more direct.


Phase 4 — The thesis, in your own words (5 min)

No code. Write 3–5 sentences answering:

  1. What behavior did the distillation install in the 1.5B student? Did it install new capability, or steer existing capability to the surface? What is your evidence?
  2. R1's distillation used SFT-only (no RL on the student). Why does SFT-only suffice if reasoning is a steering problem? What would you expect if reasoning were a knowledge problem instead?
  3. The student and the base are the same size (1.5B). What does the difference in their output tell you about what fine-tuning does — and does not — change?

Deliverables

Submit ftdd07-lab-report.md:


Solution key


Stretch goals

  1. Try a harder problem. Swap the math problem for one requiring multi-step reasoning (e.g., a probability question or an AIME-style problem). Observe whether the student's chain grows longer and whether it still self-corrects. This is the adaptive compute behavior distillation transfers.
  2. Vary the base. Repeat Phase 2 with a larger non-distilled model (e.g., Qwen2.5-7B). Does the larger base produce deliberation without distillation? This tests whether the capability was latent (larger base shows it spontaneously) versus truly absent.
  3. Inspect the chat template. Print tokenizer.chat_template for the distilled student and note the <think> scaffolding baked into the template. This is the format anchor from R1's cold-start stage, inherited by the student. (Connects to Module FT07 — tokenizers and chat templates.)
# Lab Specification — Module FTDD-07: DeepSeek-R1

**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FTDD-07 — DeepSeek-R1
**Duration**: ~45 minutes
**Environment**: Python 3.11+. A consumer GPU (RTX 4090 / 16–24GB) OR Apple Silicon (M-series, 16GB+ unified) OR free Google Colab T4. ~5GB free disk.

---

## Learning objectives

By the end of this lab you will have:

1. **Loaded R1-Distill-Qwen-1.5B** and observed its structured `<think>` chain-of-thought on a math problem — felt the distilled reasoning behavior.
2. **Compared it against a non-distilled Qwen-1.5B base** on the same problem — witnessed what the distillation transferred (the chain) and what it did not (new capability — the base flails because it was never steered to deliberate).
3. **Measured the chain-of-thought structure** — token length, presence of self-correction, presence of a verifiable final answer — and stated, in your own words, what the distillation installed.
4. **Connected the result to the thesis**: the distilled model inherits the *behavior* of reasoning (the trace shape), not new *ability* — which is why SFT-only distillation works at all.

This lab is about *observing* transferred reasoning, not training. You witness what 800K curated traces install in a 1.5B student.

---

## Phase 0 — Environment setup (5 min)

```bash
python3.11 -m venv ftdd07-env && source ftdd07-env/bin/activate
pip install -q transformers accelerate torch
# On Apple Silicon, torch uses MPS automatically. On CUDA, it uses CUDA.
```

Verify the stack:

```python
import torch
print(f"PyTorch: {torch.__version__}")
print(f"MPS available: {torch.backends.mps.is_available()}")
print(f"CUDA available: {torch.cuda.is_available()}")
```

The 1.5B model in FP16 needs ~3GB VRAM. CPU works but is slow (~3–8 tok/s); MPS/CUDA is comfortable.

---

## Phase 1 — Load R1-Distill-Qwen-1.5B (the student) (8 min)

```python
from transformers import AutoModelForCausalLM, AutoTokenizer

STUDENT_ID = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"  # the distilled student

tokenizer = AutoTokenizer.from_pretrained(STUDENT_ID)
student = AutoModelForCausalLM.from_pretrained(
    STUDENT_ID,
    torch_dtype=torch.float16,
    device_map="auto",
)
student.eval()

def generate(model, prompt, max_new_tokens=1024):
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, temperature=1.0)
    return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

# A problem that rewards deliberation
MATH_PROBLEM = "A train travels 60 km in 45 minutes. What is its average speed in km/h? Show your reasoning."

print("=== R1-DISTILL-QWEN-1.5B (the distilled student) ===")
student_output = generate(student, MATH_PROBLEM)
print(student_output)
```

**Record**: the full output. Look specifically for: (a) a `<think>` block or visible chain-of-thought, (b) self-correction ("wait," "let me check"), (c) a clearly stated final answer with units.

> **What just happened (the teaching moment):** This 1.5B model produces a structured reasoning chain. It deliberates. It was *never* trained with RL — it inherited this behavior via SFT on R1's curated traces. The behavior was transferred; the 1.5B base always had latent capability. The traces steered it to the surface.

---

## Phase 2 — Load a non-distilled Qwen-1.5B base for comparison (8 min)

```python
BASE_ID = "Qwen/Qwen2.5-1.5B"  # a non-distilled instruction-tuned Qwen of the same size

base_tokenizer = AutoTokenizer.from_pretrained(BASE_ID)
base = AutoModelForCausalLM.from_pretrained(
    BASE_ID,
    torch_dtype=torch.float16,
    device_map="auto",
)
base.eval()

def generate_base(model, prompt, max_new_tokens=512):
    messages = [{"role": "user", "content": prompt}]
    text = base_tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = base_tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
    return base_tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

print("=== QWEN2.5-1.5B (non-distilled, same size) ===")
base_output = generate_base(base, MATH_PROBLEM)
print(base_output)
```

**Record**: the base's output. It will typically answer more directly, with shorter or no visible deliberation, and may get the arithmetic wrong (45 minutes = 0.75 hours; 60/0.75 = 80 km/h — a common error is treating 45 min as 0.45 h).

---

## Phase 3 — Measure what the distillation installed (8 min)

```python
def analyze_chain(text):
    has_think = "<think>" in text or text.strip().startswith("Think") or "**reasoning**" in text.lower()
    has_self_correct = any(w in text.lower() for w in ["wait", "let me", "actually", "reconsider", "check"])
    length = len(text.split())  # rough token proxy
    # crude final-answer extraction
    lines = [l.strip() for l in text.split("\n") if l.strip()]
    final_line = lines[-1] if lines else ""
    return {
        "visible_chain": has_think,
        "self_correction": has_self_correct,
        "word_count": length,
        "final_line": final_line[:120],
    }

print("=== STUDENT (distilled) ===")
print(analyze_chain(student_output))
print("\n=== BASE (non-distilled) ===")
print(analyze_chain(base_output))
```

**Record**: both analyses side by side. Expect the distilled student to show: longer output, self-correction markers, a more explicit final answer. The non-distilled base will typically be shorter and more direct.

---

## Phase 4 — The thesis, in your own words (5 min)

No code. Write 3–5 sentences answering:

1. What behavior did the distillation install in the 1.5B student? Did it install *new capability*, or steer *existing* capability to the surface? What is your evidence?
2. R1's distillation used SFT-only (no RL on the student). Why does SFT-only suffice if reasoning is a steering problem? What would you expect if reasoning were a knowledge problem instead?
3. The student and the base are the same size (1.5B). What does the difference in their output tell you about what fine-tuning does — and does not — change?

---

## Deliverables

Submit `ftdd07-lab-report.md`:

- [ ] Phase 1: the distilled student's full output on the math problem (note the chain structure)
- [ ] Phase 2: the non-distilled base's output on the same problem
- [ ] Phase 3: the `analyze_chain` results for both, side by side
- [ ] Phase 4: your 3–5 sentence thesis statement

---

## Solution key

- **Phase 1**: the distilled student produces a visible chain-of-thought. It typically restates the problem, converts 45 minutes to 0.75 hours, computes 60 / 0.75 = 80 km/h, and may self-correct or verify. The `<think>` structure (or visible deliberation) is the transferred behavior.
- **Phase 2**: the non-distilled base often answers more directly. A common failure: treating 45 minutes as 0.45 hours (yielding ~133 km/h, wrong). Or correct but without visible deliberation. The point is the *difference in process*, not always the final answer.
- **Phase 3**: the student typically shows higher word count, self-correction markers, and a more explicit final answer. The base is shorter and more direct. (Exact numbers vary by run; the *contrast* is the lesson.)
- **Phase 4**: a correct thesis statement names (a) the distillation installed the *behavior* of deliberation (chain structure, self-correction), steering existing capability to the surface — not new ability; (b) SFT-only suffices because reasoning is steering — the 1.5B base already had latent capability from pretraining, and the traces redirect probability mass to express it. If reasoning were knowledge, SFT on traces could not transfer it; (c) the difference in output between same-size models shows fine-tuning changes *behavior* (how the model reasons), not *capability ceiling* (what it can in principle do).

---

## Stretch goals

1. **Try a harder problem.** Swap the math problem for one requiring multi-step reasoning (e.g., a probability question or an AIME-style problem). Observe whether the student's chain grows longer and whether it still self-corrects. This is the adaptive compute behavior distillation transfers.
2. **Vary the base.** Repeat Phase 2 with a larger non-distilled model (e.g., Qwen2.5-7B). Does the larger base produce deliberation without distillation? This tests whether the *capability* was latent (larger base shows it spontaneously) versus truly absent.
3. **Inspect the chat template.** Print `tokenizer.chat_template` for the distilled student and note the `<think>` scaffolding baked into the template. This is the format anchor from R1's cold-start stage, inherited by the student. (Connects to Module FT07 — tokenizers and chat templates.)