# Lab Specification — Module FTDD-08: Qwen3

**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FTDD-08 — Qwen3
**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. ~6GB free disk.

---

## Learning objectives

By the end of this lab you will have:

1. **Loaded a Qwen3 model** and run the same query in both thinking and non-thinking modes — felt the two coexisting modes in one set of weights.
2. **Toggled the thinking budget** and measured the trade-off: token spend, latency, and answer quality for a low versus high budget.
3. **Observed adaptive compute directly** — run a trivial query and a hard query at the same budget, and seen the model spend tokens proportional to difficulty.
4. **Stated, in your own words, why fusion plus the budget is the production answer** to the dedicated-reasoner latency problem.

---

## Phase 0 — Environment setup (5 min)

```bash
python3.11 -m venv ftdd08-env && source ftdd08-env/bin/activate
pip install -q transformers accelerate torch
```

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()}")
```

A 4B model in FP16 needs ~8GB VRAM. For tighter hardware, use Qwen3-1.7B (~3.5GB). CPU works but is slow.

---

## Phase 1 — Load Qwen3 and run in non-thinking mode (8 min)

```python
from transformers import AutoModelForCausalLM, AutoTokenizer
import time

MODEL_ID = "Qwen/Qwen3-4B"   # or "Qwen/Qwen3-1.7B" for smaller hardware

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

def generate(prompt, enable_thinking=False, max_new_tokens=1024):
    # Qwen3 uses /no_think or the thinking flag to control mode
    if not enable_thinking:
        prompt = prompt + " /no_think"
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True,
                                          enable_thinking=enable_thinking)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    start = time.time()
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, temperature=1.0)
    elapsed = time.time() - start
    response = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
    tok_count = out.shape[1] - inputs["input_ids"].shape[1]
    return {"text": response, "tokens": tok_count, "seconds": round(elapsed, 2)}

HARD_QUERY = "Prove that the sum of two odd integers is always even."

print("=== NON-THINKING MODE (fast) ===")
fast = generate(HARD_QUERY, enable_thinking=False, max_new_tokens=256)
print(f"Tokens: {fast['tokens']} | Time: {fast['seconds']}s")
print(fast["text"])
```

**Record**: the token count, latency, and the answer quality. Non-thinking mode should answer directly and quickly.

> **What just happened:** You ran the SAME model in its fast, direct mode. The weights are identical to what you will use for thinking mode next. This is the fusion property — one model, this mode selected by the control signal.

---

## Phase 2 — Run the same query in thinking mode (8 min)

```python
print("=== THINKING MODE (deliberate) ===")
slow = generate(HARD_QUERY, enable_thinking=True, max_new_tokens=2048)
print(f"Tokens: {slow['tokens']} | Time: {slow['seconds']}s")
print(slow["text"])
```

**Record**: the token count, latency, and answer quality. Thinking mode should produce a longer chain with visible deliberation (look for `<think>` content, self-correction, step-by-step structure).

**Compare**: tokens (thinking should be substantially higher), latency (thinking should be slower), and answer quality (thinking should be more rigorous for this proof).

---

## Phase 3 — Observe adaptive compute: trivial vs hard at the same budget (8 min)

```python
TRIVIAL_QUERY = "What is the capital of France?"

print("=== TRIVIAL QUERY, thinking mode ===")
trivial = generate(TRIVIAL_QUERY, enable_thinking=True, max_new_tokens=512)
print(f"Tokens: {trivial['tokens']} | Time: {trivial['seconds']}s")
print(trivial["text"])

print("\n=== HARD QUERY, thinking mode (from Phase 2, for comparison) ===")
print(f"Tokens: {slow['tokens']} | Time: {slow['seconds']}s")
```

**Record**: the token counts side by side. The trivial query should spend far fewer tokens even in thinking mode — the model recognizes it does not need to deliberate. This is adaptive compute: same budget permission, different spend based on difficulty.

---

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

No code. Write 3–5 sentences answering:

1. For the hard query, how much more did thinking mode spend (tokens and time) than non-thinking mode? Was the quality difference worth it?
2. For the trivial query, did the model spend the same number of tokens as the hard query in thinking mode? What does that tell you about adaptive compute?
3. If you were deploying this model as a general chatbot with occasional hard questions, how would you set the thinking budget, and why is this better than deploying a dedicated reasoner like R1?

---

## Deliverables

Submit `ftdd08-lab-report.md`:

- [ ] Phase 1: non-thinking output for the hard query (tokens, time, text)
- [ ] Phase 2: thinking output for the hard query (tokens, time, text); the comparison
- [ ] Phase 3: trivial-query thinking output (tokens, time); the adaptive-compute comparison
- [ ] Phase 4: your 3–5 sentence production thesis

---

## Solution key

- **Phase 1**: non-thinking mode produces a direct answer, typically under ~150 tokens, in a few seconds. The proof may be terse or slightly incomplete — it is answering fast, not deliberating.
- **Phase 2**: thinking mode produces a substantially longer response (often 2–5x the tokens), slower, with visible structure: definitions (odd = 2k+1), the algebraic step (2k+1 + 2m+1 = 2(k+m+1)), a conclusion. Self-correction may appear. Quality is more rigorous.
- **Phase 3**: the trivial query ("capital of France") should spend far fewer tokens even with thinking enabled — the model recognizes no deliberation is needed. This is the adaptive-compute behavior. Expect the hard query to spend multiples more.
- **Phase 4**: a correct thesis names (a) thinking mode spends substantially more tokens/time for higher quality on hard queries; (b) the model spends tokens proportional to difficulty even at the same budget — adaptive compute; (c) for a general chatbot, set a low default budget so simple traffic is fast, and raise per query (or per endpoint) for the hard-question path. This is better than a dedicated reasoner because one deployment serves both workloads without a router or second model.

---

## Notes on environment and compatibility

- **Chat template and thinking flag**: Qwen3 models use a chat template that accepts an `enable_thinking` parameter and/or a `/no_think` control token. Exact syntax varies slightly by model version; if the above fails, inspect `tokenizer.chat_template` and adjust. The principle (one model, two modes via a control signal) is the same.
- **Sampling**: Qwen3 thinking mode is often run greedy (`do_sample=False`) for reproducibility, as in the R1 lab. For non-thinking chatbot-style responses, you may prefer light sampling.
- **Token limits**: thinking mode can produce long chains. Set `max_new_tokens` generously (2048+) for hard queries, or the chain may truncate before the final answer.

---

## Stretch goals

1. **Try a mid-budget.** Some Qwen3 variants support a numeric thinking budget (a token limit on the think block). Run the hard query at progressively higher budgets and plot tokens-vs-quality. Find the inflection point where more budget stops improving the answer.
2. **Compare to R1-Distill.** If you did the FTDD-07 lab, run the same hard query on R1-Distill-Qwen-1.5B. Note that R1 always thinks (no budget knob). This contrasts the dedicated-reasoner and hybrid designs directly.
3. **Stress the non-thinking mode.** Run a general-knowledge benchmark question (not math) in non-thinking mode and confirm it is a genuinely capable assistant, not a degraded reasoner. This validates Stage 4 (general RL) kept non-thinking strong. (Connects to the lab's core thesis.)
