I wanted to learn fine-tuning, and I wanted to do it without writing a training loop. Unsloth Studio installs natively on Apple Silicon, so the whole thing ran on a MacBook over a weekend — three LoRA runs, no Python beyond the script that built the dataset.
The target was small enough to judge honestly: turn a small model into an assistant that writes correct Nodrix sketches. What the weekend taught me was not what I expected. The assistant works, and it failed in ways specific enough to name.
The result, in one prompt
Ask the base model to control an LED from a dashboard toggle and it produces something that looks entirely reasonable and is entirely invented:
#include <Nodrix.h>
bool led = false;
Toggle toggle(0, led, "LED Control");
void setup() {
Nodrix.begin();
Nodrix.addWidget(&toggle);
}
void loop() {
Nodrix.handle();
}
There is no Toggle class, no addWidget, no handle(). The model has never heard of Nodrix, so
it pattern-matched the shape of every other IoT library it has read and confidently produced a
plausible fiction. This is the normal failure and it is worse than a refusal, because it compiles in
the reader’s head.
After three runs, the same prompt:
NODRIX_WRITE("led") {
digitalWrite(LED_BUILTIN, value.asBool());
}
That is the real API. The interesting part is what happened in between.
What “without writing code” actually means
Worth being precise about, because the claim is easy to overstate.
Unsloth Studio runs as a local web app — unsloth studio -p 8888 and a browser tab. It owns the
whole training path: the base model picker, a dropdown for LoRA /
QLoRA / full / CPT, the
hyperparameters as a form, the dataset upload, the live loss graph, run history, adapter export to
GGUF or safetensors, and a Model Arena that loads base and tuned together so you can toggle the
adapter on one prompt. There is no training script in this project because there did not need to be
one.
What I did write is build.py,
which assembles the dataset from the Nodrix docs corpus. That is the
honest split, and it is the right one: the tool absorbs the loop, and the effort lands on the data,
which is the part that actually determines whether the run is any good.
Three things about the tool that mattered more than the UI:
It sets train_on_completions: true by default, so loss lands on assistant turns only. This was
the single largest silent-failure risk in the whole setup — if loss covers the prompt tokens too,
you spend your entire budget teaching the model to write the questions rather than the answers.
Studio got it right without being asked, which is exactly the kind of default that makes a no-code
tool trustworthy or dangerous depending on which way it went.
The run config and metrics are in a plain SQLite file at ~/.unsloth/studio/studio.db. Every
loss number in this post came out of a query against it rather than off a screenshot, which is what
made comparing three runs possible at all.
Name your runs properly. I called all of them nodrix-lora and then spent real time working out
which adapter in ~/.unsloth/studio/outputs/ belonged to which experiment. nodrix-v3-2ep costs
nothing at creation time and saves an afternoon later.
The genuinely low-code part is not that it’s easy. It’s that the whole weekend was spent on data and diagnosis instead of on a training harness.
Buy capability, spend data on propensity
The governing principle, which every result here turned out to be a corollary of: fine-tuning adjusts propensities; it cannot install capabilities.
So capability gets bought with the base model and the small dataset gets spent entirely on
propensity. That made the model choice easy:
Qwen2.5-Coder-7B-Instruct, which already
writes competent embedded C++. I was never going to teach a general-purpose 0.5B model to write
Arduino with 246 examples, and trying is the most common way this kind of project fails.
One licensing trap worth flagging, because it is easy to walk into: Qwen2.5-Coder ships
3B under qwen-research, which is
non-commercial, while the 1.5B and 7B are Apache-2.0. A LoRA
adapter is a derivative of its base, so training on the 3B would have permanently bound the
assistant to a non-commercial licence. The convenient middle size was the one to avoid.
The dataset is the project
246 chat examples, assembled from the Nodrix guides corpus by a build script rather than written by hand:
| source | count |
|---|---|
| FAQ pairs from guide frontmatter | 163 |
| curated code tasks from guide code blocks | 24 |
API Q&A written from Nodrix.h | 20 |
downlink examples (NODRIX_WRITE, NodrixValue) | 20 |
| failure-targeted examples from observed v2 errors | 14 |
| SDK sketches | 5 |
Split 193 train / 22 valid / 31 test, stratified by kind with a fixed seed so the splits regenerate byte-for-byte. The test set was sealed — never opened during curation — which is the only reason its number means anything. It is very easy to curate your way into a test set you have effectively memorised, and the resulting score feels great and measures nothing.
Training was LoRA at r=16, α=16, all seven linear modules, sequence length 512 (the longest example is 473 tokens), batch 2 with gradient accumulation 4, LR 2e-4 on a linear schedule — all of it typed into a form rather than a config file.
Training loss will lie to you
Run two used three epochs. Here is what that bought:

Train loss fell from 1.79 to 1.20. Eval loss reached 2.18 by step 24 and then, across forty-two more steps, improved to 2.04. That is the whole picture of overfitting in two numbers: the model spent two-thirds of its training memorising the training set and buying nothing.
Run one had no eval set at all. Its train loss looked fine. That number was worth nothing, and I would have had no way to know.
Run three cut to two epochs — fifty steps — and its eval curve descended the whole way, 2.75 to 1.83, still falling when it stopped. Stopping before the curve turns is not a compromise; it is the result.
Two more things about loss that cost me time:
Loss measures predictability, not competence. Baseline perplexity on plain English was 91, and on boilerplate Arduino it was 3.4. The code is not better understood, it is more predictable. Ranking texts by loss to decide what to train on is close to noise.
Loss is only comparable on identical text. Run three’s eval floor of 1.83 looked better than run two’s 2.04, but the validation set had grown from 20 to 22 examples between them. The comparison is confounded. Trust the shape of a curve, never a cross-run number.
Two diseases that look identical
Both runs produced wrong API calls. It took a while to see that “wrong API call” was two different illnesses wearing the same symptom, and that only one of them responds to more data.
Prior-conflict. NODRIX_WRITE("x") { } is a file-scope macro. The base model’s prior insists
that libraries expose method calls, so run two produced this:
if (Nodrix.wasWrite("led")) {
digitalWrite(LED_BUILTIN, Nodrix.valueAsBool("led") ? HIGH : LOW);
}
Correct in structure, invented in every symbol. The model wasn’t confused about what to do — it was fighting a structural habit about how libraries look.
This one cured. Seven examples mapping the exact user phrasing (“widget bound to variable X”) to
the macro, plus explicit corrections stating there is no addWidget, fixed it in run three. The
useful part: those examples used pump, fan, and speed — never led. It generalised to the
held-out led prompt, which means the model learned the mapping rather than the string.
Minority-class, or decision-against-default. A
sleeping battery sensor should use beginHTTP and poll rather
than begin and run. But begin/run dominates the entire corpus,
because most sketches are mains-powered. The model has to infer a choice that fights the house
style.
This one did not cure. Rebalancing from 4:1 to 2.3:1 didn’t move it. The battery sketch still
defaults to begin/run.
The rule of thumb I’d take to the next project: targeted data fixes phrasal mappings well and
decisions-against-a-default poorly. If the model needs to reach for a rare pattern because of
something it inferred rather than something it was told, mild rebalancing is not going to be enough.
The run-by-run evidence for both diagnoses is in
FINDINGS.md.
Two phenomena worth naming
Over-application. Having learned NODRIX_WRITE strongly, run three started using it where it
didn’t belong — stuffing an entire deep-sleep loop inside a write handler. Strengthening a pattern
has blast radius. You can over-move a propensity into neighbouring contexts, and the fix for one
prompt becomes the bug in another.
The residual hallucination floor. Run three still invented a WAKEUP_RTD_DEEP constant, mixed
ESP.deepSleep (an ESP8266 call) into an ESP32 sketch, and typo’d NODRIX.send. Even the good LED
answer carried a comment describing the host as “your Vercel hostname,” which is from nowhere.
The form was fixed. The facts still leaked. Roughly 250 examples at 7B cannot reliably suppress specific invented facts at any epoch count, and no amount of additional training on this dataset was going to change that.
The adapter that silently did nothing
One practical trap, because it cost real hours and produces no error message.
Unsloth Studio on Apple Silicon saves LoRA weights in
MLX’s layout — lora_a/lora_b with transposed shapes.
peft and transformers expect
base_model.model.….lora_A.weight and a real LoraConfig. Load the MLX adapter directly into
peft and it applies nothing, cheerfully, with no warning.
The giveaway is that greedy decoding with the adapter on and off is byte-identical. An adapter that loads without erroring is not proof it is applied — verify by generating, every time.
Where this leaves retrieval
The boundary came out of the runs rather than out of a plan. Fine-tuning reliably buys form, voice, output-format routing, and crisply-cued patterns. It does not reliably buy fact suppression or architectural decisions against a dominant prior.
That residual floor is exactly where retrieval belongs: ground the facts — the API surface, the per-board specifics — in RAG, and let the fine-tune own the form. Which is not the conclusion I expected to reach by learning fine-tuning, but it is a more useful one than “it worked.”
It also explains why the shipped answer to “let an AI drive Nodrix” is MCP rather than a fine-tune. A capable general model holding real tools beats a small model holding memorised facts, for exactly the reasons above — the tools are the retrieval, and the capability was never mine to install.
Try it
The base and tuned models are loaded side by side in a Model Arena — same weights in memory, adapter toggled, one prompt at a time. Ask it something about Nodrix and watch the invented API appear on the left.
- Live demo: nodrix-build-assistant
- Adapters: v1 1.5B · v2 7B · v3 7B
- Data pipeline, eval harness, and full findings: github.com/decoded-cipher/nodrix-llm
The dataset regenerates from the Nodrix corpus with one command, and the splits are deterministic, so the whole thing is reproducible if you want to run the experiment with different data and see where your own floor sits.