Fine-tuning Gemma 4 for Hindi on a Free GPU — and shipping it to run on a CPU
This post is the full walkthrough of the latest one: Gemma 4 E4B, fine-tuned for Hindi, trained for ₹0 on a free cloud GPU, running locally on CPU. It's part tutorial, part field notes — including the one bug that quietly destroyed an earlier model of mine, and the eval that kept me honest about what the fine-tune actually does.
If you want to skip to the result: 16-bit model · GGUF · live demo.
The setup: free GPU that actually supports bf16
I trained on Lightning.ai's free tier, which gives an on-demand L4 (24 GB). The L4 matters for one specific reason over the usual free Colab/Kaggle T4: it supports bfloat16. Gemma models are prone to float16 overflow during training, and bf16 sidesteps a whole class of NaN/instability problems. On a T4 you fight that; on an L4 it just works.
The stack:
- Unsloth for the fine-tuning (2x faster, lower VRAM, and it ships ready-made Gemma 4 notebooks)
- LoRA (r=16) — only ~0.46% of parameters are trained
- Gemma 4 E4B as the base
A quick note on E4B, because it surprised me: it runs at roughly 4B speed but has ~8B total parameters (it's a Matformer / per-layer-embeddings architecture). So the GGUF files are sized like an 8B model — the Q4_K_M lands at ~5.3 GB, not the ~2.5 GB you'd expect from a "4B." Worth knowing before you promise people a tiny download.
Loading it with Unsloth:
from unsloth import FastModel
model, tokenizer = FastModel.from_pretrained(
model_name = "unsloth/gemma-4-E4B-it",
dtype = None, # auto → bf16 on L4
max_seq_length = 2048,
load_in_4bit = True, # QLoRA
full_finetuning= False,
)
The data: two datasets, two different schemas
I used AI4Bharat's indic-instruct-data-v0.1 — specifically the anudesh and dolly subsets, Hindi splits, ~10k examples combined.
Here's a gotcha that cost me a few minutes: the two subsets have different schemas.
anudesh(hisplit) already has amessagescolumn in chat format —[{role, content}, ...]. Ready to use.dolly(hisplit) does not — it has separateinstruction/context/responsecolumns (plusquality_metricswith chrF++ scores from the back-translation).
So you can't just concatenate them. You map each into a common conversation format:
from datasets import load_dataset, concatenate_datasets
REPO = "ai4bharat/indic-instruct-data-v0.1"
anudesh = load_dataset(REPO, "anudesh", split="hi").rename_column("messages", "conversations").select_columns(["conversations"])
dolly = load_dataset(REPO, "dolly", split="hi")
def dolly_to_msgs(ex):
ctx = (ex.get("context") or "").strip()
user = ex["instruction"] if not ctx else f'{ex["instruction"]}\n\n{ctx}'
return {"conversations": [
{"role": "user", "content": user},
{"role": "assistant", "content": ex["response"]},
]}
dolly = dolly.map(dolly_to_msgs, remove_columns=dolly.column_names)
data = concatenate_datasets([anudesh, dolly]).shuffle(seed=3407).select(range(10000))
The one bug that matters: double-BOS
This is the part I most want people to read, because it cost me an entire model.
A few weeks earlier I fine-tuned Gemma 3 1B on Hindi. The result was broken in a fascinating way: it produced multilingual gibberish — it would start a Hindi sentence and drift into French, then Russian, then Thai, never actually answering. I shipped the whole package around it (model card, GGUF, demo Space) before realizing the model itself was cooked, then had to make everything private.
The likely culprit: a double BOS token.
Gemma's chat template already prepends a <bos> token. If your formatting code also adds one — or the data collator adds another on top of the template's — the model trains on inputs with two BOS tokens, and the base model's representations get scrambled. On a fragile 1B that's catastrophic.
The fix is to make sure exactly one BOS survives. When you apply the chat template during formatting, strip the leading <bos> so the collator's one is the only one:
def fmt(ex):
texts = [tokenizer.apply_chat_template(c, tokenize=False,
add_generation_prompt=False).removeprefix("<bos>") # ← the fix
for c in ex["conversations"]]
return {"text": texts}
dataset = data.map(fmt, batched=True)
# sanity check: print(dataset[0]["text"][:200]) — you should see ONE <bos>, not two
I now print a sample after formatting every single time and eyeball the BOS count. Cheap insurance.
(One more E4B-specific landmine: setting use_cache=False corrupts E2B/E4B generations into garbage, and gradient checkpointing can force that flag. Recent Unsloth patches this — so just make sure you're on the latest unsloth / unsloth_zoo. This was another way to reproduce the "gibberish" symptom, unrelated to BOS.)
The training recipe
Nothing exotic — the recipe matters less than getting the template right.
model = FastModel.get_peft_model(
model,
finetune_vision_layers = False, # text-only Hindi → freeze the vision tower
finetune_language_layers = True,
finetune_attention_modules = True,
finetune_mlp_modules = True,
r = 16, lora_alpha = 16, lora_dropout = 0, bias = "none",
)
from unsloth.chat_templates import get_chat_template, train_on_responses_only
tokenizer = get_chat_template(tokenizer, chat_template="gemma-4")
Two choices worth calling out:
finetune_vision_layers=False— E4B is multimodal, but this is a text-only Hindi model, so the vision tower is frozen dead weight we don't touch.train_on_responses_only— loss is computed only on the assistant's reply, not the user's prompt. For instruction tuning this makes a real difference.
Trainer: batch size 2, grad accumulation 4, 2 epochs, LR 1e-4 (cosine), bf16. On the L4 this took a couple of hours and landed at a final train loss of ~0.29, falling smoothly the whole way.
The principle I actually learned: verify before you package
After the 1B disaster, I made one rule: confirm the model is good before building anything around it.
So this time, the order was:
- Load the base E4B and ask it two Hindi questions → confirm the base is clean.
- Train.
- Ask the fine-tune a few Hindi questions → confirm clean, complete answers, no language drift.
- Only then build cards, quants, the demo, and the posts.
This sounds obvious. It is obvious. But last time I did it backwards and burned a session polishing a broken model. This time the order saved me — every downstream step was built on something I'd already verified worked.
An honest eval: does the fine-tune actually beat the base?
Here's where it gets uncomfortable, in a good way. I ran a side-by-side eval: base Gemma 4 vs my fine-tune, on 25 Hindi prompts (facts, reasoning, creative, instructions).
The honest finding: base Gemma 4 already speaks Hindi well. It's a 140-language model. I did not make it "smarter." But two clear, repeatable differences emerged:
1. The base constantly code-switches to English. Ask it about health and you get:
संतुलित आहार लें **(Eat a Balanced Diet)**… साबुत अनाज **(whole grains)**… अस्वास्थ्यकर वसा **(unhealthy fats)**…
My fine-tune stays in clean, native Hindi — no parenthetical English.
2. The fine-tune follows instructions more tightly. Ask for "three tips" and the base writes a 1,200-character essay with markdown headers; the fine-tune gives exactly three clean lines. Ask for a "short message" and the base offers a menu of five options; the fine-tune writes one.
And to be fair — the base is more detailed and comprehensive. If you want depth, base wins. I optimized for concise, Hindi-native, instruction-following, and edge-deployable, not for raw capability.
Saying that out loud — "here's where my model is worse" — is, I think, the most useful thing in the whole writeup. A model card that only lists wins isn't an evaluation, it's an ad.
Shipping it: GGUF for the edge
The whole point of the series is local use, so I exported GGUF quants with Unsloth:
HF = "pankajpandey-dev/gemma-4-e4b-hindi-instruct"
model.push_to_hub_gguf(HF+"-GGUF", tokenizer, quantization_method="q4_k_m")
model.push_to_hub_gguf(HF+"-GGUF", tokenizer, quantization_method="q5_k_m")
model.push_to_hub_gguf(HF+"-GGUF", tokenizer, quantization_method="q8_0")
model.push_to_hub_merged(HF, tokenizer, save_method="merged_16bit") # 16-bit
model.push_to_hub(HF+"-lora") # adapter
Gotcha: because E4B is multimodal, the GGUF export also produced a BF16-mmproj.gguf — the vision projector. For a text-only model this is dead weight (~1 GB) that confuses users. Delete it from the repo; you only ship the text quants.
Sizes (remember, ~8B total params): Q4_K_M ≈ 5.3 GB, Q5_K_M ≈ 5.8 GB, Q8_0 ≈ 8.0 GB. Q4_K_M runs comfortably on a laptop with 8 GB+ RAM, on CPU.
The demo: a Gradio Space on free CPU
I built a demo Space that runs the Q4 GGUF via llama-cpp-python on a free CPU Space. Three things that bit me, so you don't have to:
- Use the prebuilt CPU wheel. Building
llama-cpp-pythonfrom source on a Space takes forever and can fail. Pin the Space to Python 3.11 and use--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpuso pip pulls a prebuilt wheel. - Pin Gradio. A recent Gradio version crashed on schema parsing (
argument of type 'bool' is not iterable). Pinning to a known-good version fixed it. - Build the Gemma prompt manually. Relying on
create_chat_completion's template gave me one-word replies. Constructing the turn format explicitly (<start_of_turn>user … <start_of_turn>model) with<bos>and proper stop tokens (<end_of_turn>) gave full, clean answers.
On CPU each token takes ~1–2 seconds, so I added an animated typing indicator — on a slow backend, showing the model is working matters as much as the output.
What didn't work: Ollama
I wanted a one-line ollama run for the model. It doesn't work yet — ollama create fails with llama-quantize failed: exit status 1 on Gemma 4 E4B GGUFs. This is an upstream Ollama bug (tracked in ollama/ollama#15447, the gemma4 architecture), not a problem with the model — the same GGUF runs fine in llama.cpp, LM Studio, and llama-cpp-python.
I tried updating Ollama, deleting the mmproj, and the workaround template from the issue thread. No luck. So I documented it honestly on the model card and moved on. The model is fully usable today; Ollama will catch up.
Takeaways
If you're starting your own small fine-tune:
- Use a bf16-capable free GPU (L4 on Lightning) over a T4 — it removes a whole class of training instability.
- Get the chat template right before anything else. Double-BOS will silently wreck a small model. Print a formatted sample and count your BOS tokens.
- Verify the model before you package it. Cards, quants, demos, and posts are all wasted effort on a broken model. Confirm first.
- Evaluate honestly, including where you lose. "Here's what my model is worse at" is more credible than a list of wins.
- GGUF + a CPU demo is what makes a model usable by people without GPUs — that's the whole value of small models.
The model is live: 16-bit · GGUF · demo. It's part of my ongoing Hindi LLM Series — small, open, edge-friendly Indic models, one a week.