← Back to blog

I fine-tuned Qwen2.5 to talk like Yoda, here's what it taught me

9 min read
LoRAFine-tuningLLMsQwen2.5Machine LearningPython

Wanted to properly learn LoRA fine-tuning, so instead of another roadmap I picked a small project and did it end to end: teach Qwen2.5-1.5B to translate plain English into Yoda-speak. "Read this, you must." That kind of thing.

Toy project, sure. But it surfaced real lessons about fine-tuning that only show up once you actually run the experiments. Here's the whole thing.

The setup

  • Model: Qwen2.5-1.5B-Instruct. Small enough to iterate fast, capable enough to be worth it.
  • Dataset: yoda_sentences on Hugging Face. 720 paired examples, plain English to Yoda-syntax.
  • Hardware: a rented RTX 4090 on RunPod (~$0.40-0.70/hr). Total cost for the whole project: a few dollars.
  • Method: LoRA, not QLoRA.

Full fine-tuning vs LoRA vs QLoRA

Quick explainer, with an analogy instead of the usual matrix math.

Think of the base model as a chef who's already mastered thousands of recipes. That's pretraining. Expensive, slow, took a huge kitchen to get there.

Full fine-tuning sends that chef back to culinary school to relearn everything, just to teach them one new style of cooking. Every skill gets touched, not just the one you care about. Works, but it's slow, needs a lot of "kitchen space" (GPU memory), and there's a real risk the chef comes back slightly worse at things they already knew.

LoRA skips all that. Instead of retraining the chef, you hand them a small set of sticky notes. "Plate this dish this way." "When someone asks for it spicy, add this instead." Worth being precise: these notes aren't teaching the chef new ingredients or new facts. They're adjusting how the chef uses skills they already have, behavior and style, not new knowledge. Core knowledge stays untouched, you're only writing a handful of notes. In my case that meant training 0.141% of the model's parameters. Everything else stayed frozen.

That's also why it's cheaper. You're storing a handful of sticky notes, not the whole recipe book. And it's why LoRA fits this task so well: Yoda-speak is a style change on top of a model that already knows English, not new information it needs to learn from scratch.

QLoRA takes it one step further. Smaller kitchen (less GPU memory)? Shrink the recipe book itself before handing over the sticky notes, compress it down, keep the notes at full detail. That's quantization: the frozen base model stored in a lower-precision, more compact format, while the small trainable adapters stay precise. You lose a bit of shelf-space efficiency but fit a much bigger recipe book into a much smaller kitchen. That's what lets people fine-tune 7B-70B models on one consumer GPU.

Didn't need QLoRA here. Qwen2.5-1.5B fits comfortably on a 24GB card without shrinking anything. QLoRA earns its keep once the model is much bigger relative to your hardware.

The actual config I used

The sticky-notes analogy is nice, but at some point you have to pick real numbers. Here's what I went with, and why:

  • r (rank) = 8: this controls how much capacity the sticky notes get. Higher rank means more room to learn, but also more parameters and more risk of overfitting on a small dataset. Yoda-speak is a narrow, simple task, reordering words, so it doesn't need much capacity. 8 was plenty.
  • alpha = 16: this scales how strongly the adapter's changes get applied on top of the frozen model. Common convention is alpha = 2 × r, so 16 followed straight from picking r = 8.
  • target_modules: attention only (q_proj, k_proj, v_proj, o_proj): this is which parts of the model actually get sticky notes attached. I skipped the MLP layers (gate_proj, up_proj, down_proj) on purpose. Attention layers are mostly about relationships between words, which is exactly what Yoda-speak reordering needs. MLP layers hold more of the model's factual knowledge, and I wasn't trying to teach it new facts, just a new word order.
  • bias = "none": didn't bother making the model's bias terms trainable. They carry very little of a model's actual capacity, and Qwen2.5 barely uses them in its linear layers anyway.
  • Learning rate = 2e-4: LoRA tolerates a much higher learning rate than full fine-tuning, since you're only updating a tiny sliver of the model's parameters. 2e-4 is common for LoRA runs like this, versus something like 2e-5 you'd use for full fine-tuning. End result: 0.141% of the model's parameters trainable. Everything else frozen exactly as it was pretrained.

Step one: actually look at your data

Before writing any training code, I read through the dataset. Checked duplicates, nulls, weird outliers. Found one row where the "before" and "after" sentence were identical. Instead of assuming it was an error and dropping it, I checked. Turned out that sentence's structure already matched Yoda syntax naturally, nothing to translate. Kept it.

Small thing, but it set the tone: don't assume, check.

Data is the most underrated yet the heart of fine-tuning.

Formatting the data properly

Qwen2.5-Instruct expects a specific chat format (ChatML), so I used the model's own tokenizer to build training examples instead of hand-writing tags myself. Get this wrong and you're fighting the model's existing training instead of building on it.

Also rotated through 4 different instruction phrasings ("Translate this into Yoda-speak," "Say this the way Yoda would," etc.) instead of repeating one fixed phrase 720 times. Otherwise the model risks learning "this exact sentence means reorder words" instead of the actual task.

Watching overfitting happen on purpose

First run: 3 epochs. Training loss dropped nicely, validation loss too, though the gain from epoch 2 to 3 was small. Looked like a fine stopping point.

Wanted to actually see the overfitting curve instead of assuming, so I reran it for 8 epochs (epochs are the number of times you go train through the same dataset):

  • Epoch 2 — Train loss: 0.150, Val loss: 0.238 (lowest)
  • Epoch 3 — Train loss: 0.118, Val loss: 0.256
  • Epoch 8 — Train loss: 0.002, Val loss: 0.521

Training loss basically hit zero. Validation loss more than doubled. Classic overfitting: the model went from learning the pattern to memorizing the training set. Only obvious once I had enough epochs to see the trend. The 3-epoch run alone would've fooled me into thinking things were still improving.

Why I stopped trusting the loss number

By the numbers, epoch 2 was the "best" checkpoint. Lowest validation loss, textbook pick. So I generated real outputs from epoch 2 and epoch 3 and read them side by side.

Epoch 2:

"Equal to that, the earth move, the force would."

Epoch 3, same input:

"To move the earth, a force equal to that would."

Epoch 3 is clearly better. Grammatical, coherent, not even close. But its validation loss was technically worse.

Same pattern across a full 72-sentence test set: epoch 3 fixed 5 out of 9 broken outputs from epoch 2, despite "losing" on the metric everyone tells you to optimize.

Lesson: at small dataset sizes, a validation loss gap of 0.002 is basically noise. Not a reliable tie-breaker. You have to read the actual outputs.

Checking if fine-tuning even mattered

Good question to ask properly rather than assume. Ran the same 72 test sentences through the un-fine-tuned base model as a control.

The base model, even when explicitly told "translate this into Yoda-speak," mostly didn't:

  • Wrote generic old-timey/Shakespearean English instead of actual Yoda syntax
  • Sometimes just repeated the input back unchanged
  • Hallucinated a Star Wars "the Force" reference that had nothing to do with the input
  • Got real Yoda syntax right on maybe 5 out of 72 sentences

So yes, fine-tuning genuinely taught the model something it couldn't do before. Not a placebo, not just formatting polish. Real capability gain from 576 training examples and about 26 seconds of training.

Stress-testing with weirder inputs

Wrote 27 sentences deliberately unlike the training data: questions, negation, first-person sentences, cricket and gaming vocabulary, conditionals.

Biggest weak spot: questions. Training data had basically zero interrogative sentences, and it showed, broken subject-verb agreement, weird tacked-on tag questions. Good reminder your model only generalizes as far as your data lets it.

Also ran an original short paragraph with dialogue and a proper noun through epoch 8 and epoch 3. Epoch 8 occasionally produced actual broken, garbled text on the hardest sentence. Epoch 3 played it safer, sometimes under-transformed instead of fully committing to Yoda syntax, but never broke outright.

That was the deciding factor: a model that quietly plays it safe on hard input beats one that occasionally produces garbage, even if the second one looks more impressive when it works.

Where it ended up

Locked in epoch 3, merged the LoRA adapter into the base model, pushed the whole thing to Hugging Face. Real, working Yoda-speak translator, sitting on the Hub for anyone to try.

What I'm taking away from this

  1. Read your data before you touch training code. Investigate anomalies instead of assuming they're errors.
  2. Match your training format to how the base model actually expects input. Don't guess.
  3. Small validation loss differences are often noise. Read your model's actual outputs before trusting a metric.
  4. Always run a base-model control. Otherwise you have no idea if fine-tuning did anything at all.
  5. Test with inputs unlike your training data on purpose. Your held-out test set can share every blind spot your training data has.
  6. Different kinds of "wrong" matter differently. A model that plays it safe is often more useful than one that's occasionally spectacular and occasionally broken.

Total cost for the whole project: a few dollars in GPU time. Genuinely a good way to actually learn this stuff instead of just reading about it.

Model's up on Hugging Face, Would love any feedback if you play around with it. 😁

Links: