Lessons available in both languages
Gen AI · Interview Prep

Fine-Tuning interview questions & answers

158+ real Fine-Tuning interview questions with model answers, plus free lessons to learn the concepts. Prepare in English & Hinglish, then practise with an AI mock interview.

13 topics · 158+ questions

How Hirenix teaches

One chapter. 90 minutes.
Interview-ready.

Every concept starts with a real-world problem — the kind that actually shows up in production code. Nothing to cram; it just clicks. Every question comes with a model answer: exactly what to say in the room, and why. Then an AI mock interview on the same chapter.

  • 📖Concept in 5 minutesNo jargon — straight to the point
  • 🛠️Real-world problemThe kind production code throws at you
  • 💬Model answerExactly what to say in the room
  • 🧠FlashcardsRevise in 10 minutes
  • 🤖AI mock interviewIt asks follow-ups too
  • 📊Weak topicsSee exactly where you're stuck
Start this chapter — free🌐 English🇮🇳 Hinglish
A student learning an interview concept on Hirenix at home
Video playlistbuilt around a syllabus18h+
Hirenix chapterbuilt around interviews90 min

The difference isn’t the content — it’s the filter. Only what’s actually used in production and actually asked in interviews. Textbook topics the industry never touches don’t make the cut.

Lessons available in both languages

What you’ll learn

  • What is fine-tuning?
  • When to fine-tune (and when not to)
  • Dataset preparation (JSONL)Free account
  • Data quality and validationFree account
  • The hosted fine-tuning APIFree account
  • Training hyperparametersFree account
  • Hugging Face: open-source modelsFree account
  • LoRA and QLoRAFree account
  • Evaluating a fine-tuned modelFree account
  • RecapFree account
  • Project: Brand-voice support bot
  • Project: Local LoRA fine-tuneFree account
  • Project: Fine-tune evaluation harnessFree account

What is fine-tuning?

Picture two ways to bring a new hire up to speed. Method one: hand them a fresh checklist every single shift -- they follow it perfectly today, but if you don't hand it to them tomorrow, they have no idea what to do. Method two: keep them on the floor for months of drills until the correct response is muscle memory -- you can quietly take the checklist away and they still do it right, because how they act has permanently changed.

Everything you've learned in this course so far is method one, at scale. A prompt is a checklist you hand the model on every single call -- better instructions, but re-explained every time. RAG (Ch4) hands the model a reference binder (retrieved documents) it can flip through during that one call, then forgets. Neither changes the model itself; both are re-supplied, call after call.

Fine-tuning is method two. You show the model hundreds of examples of the behaviour you want, and instead of feeding it back in as a prompt, you continue training the model on them -- its internal weights shift, permanently, so that behaviour is now baked in. You no longer have to re-explain it every call.

In the OpenAI fine-tuning API, this shift is literal: you upload a file of examples, a training job runs on it, and it produces a brand-new model ID. Call that model ID and the trained behaviour is just... there, with a much shorter prompt than before.

Here's the trap almost every beginner falls into: "let's fine-tune it on our product manual so it knows our products." That is not what fine-tuning is for. Training on facts does not build a reliable lookup -- it shifts a probability distribution, so the model will often sound confident while getting the facts wrong, and you cannot cite or update what it 'learned'. Fine-tuning teaches a model how to respond -- its tone, its format, its habits on a narrow task -- not what it knows. Facts that need to be current, correct and citable are RAG's job, not fine-tuning's.

That gives you the ladder this whole chapter climbs: try a better prompt first, then RAG if you need current facts, and reach for fine-tuning last -- only once neither of those gets you the consistency you need.

🌍 Real-world example: a support team wants every reply to sound like their brand -- warm, three sentences, always offering a follow-up. Instead of pasting a long style guide into every prompt, they fine-tune a model on hundreds of examples of that voice; the short prompt in the code below is what the fine-tuned model needs instead of the long one.

💡 Fine-tuning = continuing a model's training on your own examples so its weights shift toward a wanted behaviour.

💡 Weights = the model's internal numbers; fine-tuning is the process of changing them -- prompting and RAG never do.

💡 The ladder = prompting -> RAG -> fine-tuning, in order of effort and cost; climb only when the rung below can't give you the consistency you need.

Standard definition: Fine-tuning is the process of continuing a pretrained model's training on your own labelled examples so its weights shift toward a specific behaviour; unlike prompting or RAG, which supply information at request time and are re-sent on every call, fine-tuning bakes that behaviour into the model itself -- it changes how the model responds, not what it factually knows, which is why it complements RAG rather than replacing it.

import tiktoken

# The long instruction-heavy system prompt you'd need WITHOUT fine-tuning --
# every rule spelled out, every single call.
long_system_prompt = """You are a customer support assistant for an e-commerce company.
Always respond in a warm, friendly, and professional tone.
Never use slang or overly casual language.
Always format your response as a short paragraph, no bullet points.
Always end your response by asking if there is anything else you can help with.
Never mention competitor brands or products.
Always confirm the customer's order number before discussing order details.
If the customer sounds upset, acknowledge their frustration before solving the problem.
Keep responses under 150 words unless the customer asks for more detail.
Never make promises about refund timelines outside official policy."""

# The short prompt a FINE-TUNED model needs instead -- the voice/tone/format rules
# above are now baked into the weights, so they don't have to be re-explained.
short_prompt_for_finetuned_model = "Customer: My order hasn't arrived yet, what do I do?"

# tiktoken is OpenAI's own tokenizer -- it counts tokens the way OpenAI models see
# them. It is not a general-purpose tokenizer: other model families use their own
# tokenizers and would count the same text differently.
enc = tiktoken.get_encoding("o200k_base")

long_tokens = enc.encode(long_system_prompt)
short_tokens = enc.encode(short_prompt_for_finetuned_model)

print("long instruction-heavy system prompt tokens:", len(long_tokens))
print("short prompt (fine-tuned model) tokens:", len(short_tokens))
print("per-call token saving:", len(long_tokens) - len(short_tokens))

When to fine-tune (and when not to)

Picture onboarding a new support hire. You could (a) hand them a cheat-sheet to re-read before every call, (b) give them a folder of past tickets to flip through per call, or (c) put them through weeks of training until the company's voice and rules become instinct, no cheat-sheet needed. Those are, in order, prompting, RAG, and fine-tuning -- and you climb this ladder one rung at a time, only when the rung below can't give you the consistency you need.

In the API, this maps directly: prompting = instructions you resend every call; RAG = documents you retrieve and paste in every call; fine-tuning = weight updates baked in once, so the model behaves that way without either. Each rung costs more setup and maintenance than the one below, so most teams never actually need to fine-tune -- try prompting first, then RAG, and reach for fine-tuning only when neither gives you the consistency you need.

What do you want?                 Reach for
------------------                ---------
Answers grounded in your docs  ->  RAG (NOT fine-tuning!)
A consistent style/tone        ->  Fine-tuning
A specific output format        ->  Prompt engineering first
A narrow, repeated task         ->  Fine-tuning (if prompting/RAG can't nail it)
A cheaper/smaller model         ->  Fine-tune a small model to match a bigger one
Shorter prompts, lower latency  ->  Fine-tuning (behaviour baked in, less to repeat)
                 | Prompting     | RAG                  | Fine-tuning
-----------------|---------------|----------------------|---------------------------
Setup time       | Minutes       | Hours                | Hours-to-days
Data needed      | None          | Your documents        | Curated example pairs (aim for
                 |               |                        | a few hundred consistent ones)
Data freshness   | Static        | Updates the moment    | Frozen until you retrain
                 |               | you add a document    |
Best for         | General       | Facts from your docs  | Voice/tone, format, narrow
                 | instructions  |                        | task behaviour
Try it           | FIRST         | SECOND                 | LAST

When it is genuinely worth reaching for fine-tuning: a consistent brand voice across thousands of replies, a rigid output format every single time, a narrow repeated task, or making a smaller, cheaper model good enough to replace a bigger one. When it is NOT: you need fresh or factual answers from your own documents (that's RAG's job, not fine-tuning's), or you haven't even tried tuning the prompt yet.

One production pattern worth knowing by name: distillation. In its general form, you train a small model to match a larger, more expensive model's full output distribution (soft targets, not just its final answer) -- this is literally how the distil* model family was built. The version you'll meet on the job is simpler: sample the big model's answers on a bunch of inputs, then supervised-fine-tune the small model on those sampled answers -- which is just ordinary fine-tuning (SFT) using a bigger model's outputs as the "gold" labels instead of human-written ones.

🌍 Real-world example: a team wants cheaper customer-support replies. Instead of paying for a big model on every ticket, they generate a few hundred good replies from it, then fine-tune (or distil into) a small model on those replies -- same voice and quality, far less compute per reply.

💡 Prompting = instructions resent in every call; zero setup, but it's repeated every time and can't hold documents or facts reliably.

💡 RAG = retrieving relevant documents and pasting them into the prompt each call, so answers can be grounded and stay fresh.

💡 Fine-tuning = continuing training on your examples so behaviour is baked into the weights, instead of re-explained every call.

💡 Distillation = training a small model to imitate a larger one -- either its full output distribution (the general/technical form) or, more commonly in practice, its sampled answers used as SFT training data.

Standard definition: Fine-tuning is the last rung of a ladder that starts with prompting and RAG -- reach for it only when you need consistent style, a rigid output format, a narrow repeated task, or a smaller model made good enough to replace a bigger one, since fresh or factual answers belong to RAG and every rung above prompting costs more setup and ongoing maintenance than the one below it.

import tiktoken

# A small illustrative brand-voice fine-tuning dataset (chat-style examples).
dataset = [
    {"messages": [
        {"role": "system", "content": "You are a friendly brand-voice support agent."},
        {"role": "user", "content": "My order hasn't arrived yet."},
        {"role": "assistant", "content": "So sorry about that! Let's track it down together - could you share your order number?"},
    ]},
    {"messages": [
        {"role": "system", "content": "You are a friendly brand-voice support agent."},
        {"role": "user", "content": "Can I get a refund?"},
        {"role": "assistant", "content": "Of course! I'll get that refund started right away - just confirm the order number for me."},
    ]},
    {"messages": [
        {"role": "system", "content": "You are a friendly brand-voice support agent."},
        {"role": "user", "content": "The product arrived damaged."},
        {"role": "assistant", "content": "Oh no, that's not the experience we want for you! Let's get a replacement sent out today."},
    ]},
    {"messages": [
        {"role": "system", "content": "You are a friendly brand-voice support agent."},
        {"role": "user", "content": "How long does shipping take?"},
        {"role": "assistant", "content": "Great question! Shipping usually takes 3-5 business days, and I'll share tracking as soon as it ships."},
    ]},
]

# tiktoken is OpenAI-only -- it does not count tokens for Claude or open-source
# models. This estimate is scoped to the OpenAI fine-tuning path only.
enc = tiktoken.get_encoding("o200k_base")

def count_example_tokens(example):
    return sum(len(enc.encode(m["content"])) for m in example["messages"])

per_example = [count_example_tokens(ex) for ex in dataset]
dataset_tokens = sum(per_example)
print("per-example tokens:", per_example)
print("dataset total:", dataset_tokens, "tokens")

for n_epochs in (1, 3):
    print(f"n_epochs={n_epochs} -> training tokens (dataset_tokens x epochs):", dataset_tokens * n_epochs)

Project: Brand-voice support bot

What we're building: a brand-voice support bot for a fictional cloud-storage product, "Nimbus" -- the FREE, first-impression project of this chapter. This is the whole fine-tuning workflow end to end, minus the one step nobody reading this can actually perform: submitting a paid hosted training job. Everything else -- designing the voice, curating examples, building the JSONL, validating it, splitting train/val, estimating training cost in tokens, and constructing the exact job payload the hosted API expects -- genuinely RUNS below, on C:/lcv, with zero API key.

This project assumes you have NOT seen topics 3-6 of this chapter yet (dataset prep, validation, the hosted API, hyperparameters) -- everything is re-derived here from scratch, self-contained.


Step 0 -- The one-line framing (read first)

Fine-tuning changes how a model responds, not what it knows. Show it 200 replies in one consistent voice and it starts writing in that voice -- it does NOT learn your refund policy or your ship dates as verifiable facts (that's RAG's job). So this project is deliberately about voice, tone and format, never about teaching the bot new information. Every example below could be answered by a human agent who already knows the facts; what we're encoding is HOW they'd phrase it.

💡 JSONL = one JSON object per line of a text file (NOT a JSON array). Each line is one training example. 💡 Validator = a script that checks a JSONL file for structural errors BEFORE you spend money training on it. 💡 Job payload = the dict of arguments a hosted fine-tuning API call expects -- constructing it is different from sending it.


Step 1 -- Design a consistent voice

Before writing a single example, write down the voice as a small spec you can check every example against:

brand: Nimbus (cloud storage support)
tone: warm, plain-English, confident
banned words: "unfortunately", "as per policy", "kindly", "team will look into it"
rules: always name ONE concrete next step; say "we" never "the team";
       no corporate jargon or hedging; keep replies under ~60 words

Why this matters more than the code: a fine-tuned model can only be as consistent as your examples are. If half your examples say "unfortunately" and half don't, the model learns a blurry average voice, not a sharp one. Turn the spec into ONE fixed system prompt and reuse that exact string in every single training example -- inconsistent system prompts between examples is one of the most common reasons a fine-tune "doesn't work."


Step 2 -- Curate the example set

Six short support conversations, each following the spec: a refund, a sync bug, a downgrade question, an account-deletion request, a crash report, and a feature request. Each example is a messages list -- system (the fixed voice prompt), user (the customer's message), assistant (the on-voice reply). A real project would want a few hundred consistent examples rather than thousands of sloppy ones -- quality and consistency matter far more than volume -- but six is enough to prove every step of this pipeline for real.

A rule worth internalizing right now, because it's a common beginner trap: never let the model see your validation examples during writing prompts for training examples -- if you generate both from the same handful of templates, your held-out split (Step 5) won't actually catch overfitting, because "held out" only means something if the examples are genuinely different situations.


Step 3 -- Build the JSONL (re-derived from scratch)

The hosted chat fine-tuning format (scoped to the OpenAI API specifically -- other providers/frameworks use different shapes) is one JSON object per line, never a JSON array wrapping everything. Each line's object has one key, messages, holding the same three-role list from Step 2. The code below writes each of the six examples as its own line with a trailing newline -- no square brackets, no commas between examples, just one complete object per line. This step ran for real; a peek at the first raw line confirms it's genuinely one flat JSON object, not part of a list.


Step 4 -- Re-derive the validator, and run it on a GOOD and a BAD file

Before spending anything on a training run, a validator checks every line for the mistakes that actually break a fine-tune: is each line valid JSON at all, does every message have an allowed role (system / user / assistant), is the LAST message in each example the assistant turn (that's the one thing actually being learned -- a user-last row teaches nothing), and are there duplicate examples (identical messages lists, which waste a training slot and skew the model toward that one reply).

Run against our six clean examples, the validator reports zero errors. To prove it actually catches problems and isn't just decoration, a second file was built with three deliberately different, real mistakes: one row ending on a user message instead of assistant, one row with genuinely malformed JSON (a missing comma), and one exact duplicate of an earlier example. The validator caught all three, each with a distinct, specific error message -- this is the check you run before you ever touch the hosted API.


Step 5 -- Train/val split

A validated file still needs a held-out split: examples the model never trains on, used later to check whether it's actually learning the voice or just memorizing. The split below shuffles with a fixed seed (so it's reproducible) and takes roughly 20% for validation, rounding up to at least one example. On six examples that's five for training and one held out -- small, but the exact same split logic scales to a few hundred examples in a real dataset.


Step 6 -- Estimate training tokens (no currency, ever)

Training cost scales with dataset_tokens x epochs, and printing a currency figure here would be teaching a number that rots the moment the provider updates its price list -- so this estimator prints tokens only. tiktoken is OpenAI-only -- it counts tokens correctly for the OpenAI API and undercounts Claude or other providers, so treat this number as OpenAI-scoped from the start. The +4 added per message is a rough approximation of per-message formatting overhead, not the provider's exact accounting -- real byte-for-byte billing is whatever the provider's current docs say. Multiplying the six-example dataset's total tokens by 3 epochs gives the real training-token count below -- that number is what actually drives a hosted training bill, however the provider prices it today.


Step 7 -- Construct the job payload -- and stop there

This is the step every earlier chapter build-up leads to, and the one this project deliberately does NOT take further. The current OpenAI Python SDK expects a training job to be created with model, training_file, validation_file, and the modern method={"type": "supervised", "supervised": {"hyperparameters": {...}}} block (the flat hyperparameters= argument still exists but is the legacy shape). The code below builds exactly that shape as a plain Python dict -- training_file and validation_file are placeholders here because they'd normally be file IDs returned by a separate files.create(file=..., purpose="fine-tune") upload call, and the model name is a placeholder too, because a model ID is a volatile fact you should look up from the provider's current fine-tunable list at the time you actually build this, not copy from a tutorial.

This dict is printed, never sent. Constructing it and confirming its shape is real, verified work: it matches the current SDK's argument names exactly. Submitting it is the one step this project cannot end on.


Step 8 -- Appendix: the illustrative submission (NOT executed)

Everything above ran for real with no API key. This appendix exists only so the shape of "what happens next" is honest and complete -- it is clearly labelled, and none of it is real output:

# ---- ILLUSTRATIVE (hosted fine-tuning is not executed here; see notes) ----
file_train = client.files.create(file=open("tone_bot_train.jsonl", "rb"), purpose="fine-tune")
file_val = client.files.create(file=open("tone_bot_val.jsonl", "rb"), purpose="fine-tune")
job = client.fine_tuning.jobs.create(
    model="<current-fine-tunable-model-id>",
    training_file=file_train.id,
    validation_file=file_val.id,
    method={"type": "supervised", "supervised": {"hyperparameters": {"n_epochs": 3}}},
)
# job.status is a plain string ("validating_files" while queued, "succeeded" when done);
# job.fine_tuned_model is a SIBLING field on the job object, None until it succeeds --
# job.status.fine_tuned_model would raise, because a str has no such attribute.
job = client.fine_tuning.jobs.retrieve(job.id)
print(job.status, job.fine_tuned_model)

Hosted fine-tuning job creation is, as of this writing, closed to organisations that haven't fine-tuned before, and the provider has said it is winding down self-serve fine-tuning generally -- so treat everything above as vocabulary, not a step to perform: files upload, a job object with a pollable status, and a new model ID that shows up on the finished job. That vocabulary is exactly what interviews ask about, and it survives even though running it yourself may not currently be possible. Hosted fine-tuning availability, pricing and eligibility are provider product decisions that change -- check the provider's current fine-tuning docs before planning to use it.


✅ What you just built -- and resume framing

You designed a consistent brand voice as an explicit spec, curated a small dataset that actually follows it, wrote a genuinely valid JSONL file, built and ran a validator that catches three real, distinct classes of dataset errors, produced a reproducible train/val split, measured the dataset's real token cost with the right tokenizer for the job, and constructed -- correctly, matching the current SDK's real argument names -- the exact payload a hosted fine-tuning job would need, without spending anything or depending on access nobody reading this reliably has.

Say this plainly, because it's true and it's the point: a validated dataset plus a reproducible pipeline IS the portfolio piece. The submitted job is not the deliverable -- the fact that your JSONL is real, your validator genuinely catches bad rows, and your split is reproducible is what an interviewer can actually inspect and trust, independent of whether any specific hosted provider's fine-tuning door happens to be open this month.

For your resume/interview: "Built a fine-tuning data pipeline for a brand-voice support bot: designed a voice spec, curated and validated a JSONL training set (catching malformed JSON, wrong last-message role, and duplicate examples), produced a reproducible train/val split, estimated training cost in tokens, and constructed a hosted fine-tuning job payload against the current SDK's argument shape." That sentence shows you understand the whole pipeline, not just that you called an API.

Standard definition: a fine-tuning project's real deliverable is the validated dataset and the reproducible pipeline that produced it -- a consistent voice spec, a JSONL file that passes structural validation, a genuine train/val split, and a correctly-shaped job payload -- because that artefact set is what proves the work regardless of whether a specific submitted training job ever ran.

import json
import random

# ---------- voice spec: the ONE thing every example must obey ----------
VOICE_SPEC = {
    "brand": "Nimbus (cloud storage support)",
    "tone": ["warm", "plain-English", "confident"],
    "banned_words": ["unfortunately", "as per policy", "kindly", "team will look into it"],
    "rules": [
        "always name ONE concrete next step",
        "use 'we', never 'the team'",
        "no corporate jargon or hedging",
        "keep replies under ~60 words",
    ],
}

SYSTEM_PROMPT = (
    "You are Nimbus support. Voice: warm, plain-English, confident. "
    "Never say 'unfortunately' or 'as per policy'. Always name one concrete next step. "
    "Say 'we', never 'the team'. Keep replies under 60 words."
)

# ---------- curated examples: SAME system prompt every time ----------
RAW_EXAMPLES = [
    ("My files stopped syncing since this morning.",
     "That's on us, not your setup -- we're aware and already rolling out a fix. "
     "Restart the Nimbus app once it lands (within the hour) and syncing will resume automatically."),
    ("I was charged twice for my subscription.",
     "Sorry for the mix-up -- we've flagged the duplicate charge and it'll refund to your original "
     "payment method within 3-5 business days. I'll email you the reference number in a minute."),
    ("How do I get my storage back after downgrading my plan?",
     "Downgrading just lowers your limit going forward -- your existing files stay put. "
     "If you're over the new limit, delete or archive files until you're under it, then sync resumes."),
    ("Can you delete my account and all my data?",
     "Done as soon as you confirm -- head to Settings -> Account -> Delete, and everything clears "
     "within 24 hours. Want me to send that link now?"),
    ("Your app crashes every time I open a video file.",
     "That's a real bug, not a fluke -- we've reproduced it and a fix ships Thursday. "
     "Until then, open videos from the web app instead; it isn't affected."),
    ("I want a feature to auto-tag photos by location.",
     "Love that idea -- I'm logging it for our product roadmap right now. "
     "Follow the changelog page and you'll see it if we build it."),
]

examples = [
    {"messages": [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": u},
        {"role": "assistant", "content": a},
    ]}
    for u, a in RAW_EXAMPLES
]

# ---------- write JSONL: one JSON object per LINE, never a JSON array ----------
GOOD_PATH = "tone_bot_good.jsonl"
with open(GOOD_PATH, "w", encoding="utf-8") as f:
    for ex in examples:
        f.write(json.dumps(ex) + "\n")

# ---------- re-derived validator (self-contained, no earlier topic assumed) ----------
ALLOWED_ROLES = {"system", "user", "assistant"}

def validate_jsonl(path):
    errors, seen, rows = [], set(), 0
    with open(path, encoding="utf-8") as fh:
        for i, line in enumerate(fh, start=1):
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as e:
                errors.append(f"line {i}: bad JSON - {e}")
                continue
            msgs = obj.get("messages")
            if not isinstance(msgs, list) or not msgs:
                errors.append(f"line {i}: missing or empty 'messages' list")
                continue
            for m in msgs:
                if m.get("role") not in ALLOWED_ROLES:
                    errors.append(f"line {i}: unknown role {m.get('role')!r}")
            if msgs[-1].get("role") != "assistant":
                errors.append(f"line {i}: last message must be assistant")
            key = json.dumps(msgs, sort_keys=True)
            if key in seen:
                errors.append(f"line {i}: duplicate example (identical messages)")
            seen.add(key)
            rows += 1
    return rows, errors

rows, errors = validate_jsonl(GOOD_PATH)
print(f"validator on GOOD file -> rows={rows} errors={errors}")

# ---------- a BAD file with 3 real, distinct violations ----------
BAD_PATH = "tone_bot_bad.jsonl"
bad_lines = [
    '{"messages": [{"role": "system", "content": "' + SYSTEM_PROMPT + '"}, '
    '{"role": "user", "content": "Where is my refund?"}, '
    '{"role": "user", "content": "Any update?"}]}',      # last message is user, not assistant
    '{"messages": [{"role": "system", "content": "oops" "broken}',  # malformed JSON
    json.dumps(examples[0]),   # first copy
    json.dumps(examples[0]),   # exact duplicate -- same messages list, byte for byte
]
with open(BAD_PATH, "w", encoding="utf-8") as f:
    for line in bad_lines:
        f.write(line + "\n")

rows_b, errors_b = validate_jsonl(BAD_PATH)
print(f"validator on BAD file -> rows={rows_b}")
for e in errors_b:
    print(" ", e)

# ---------- train/val split ----------
def split_train_val(items, val_ratio=0.2, seed=42):
    items = list(items)
    rnd = random.Random(seed)
    rnd.shuffle(items)
    n_val = max(1, round(len(items) * val_ratio))
    return items[n_val:], items[:n_val]

train, val = split_train_val(examples, val_ratio=0.2, seed=42)
print(f"split(seed=42, 80/20) on {len(examples)} rows -> train={len(train)} val={len(val)}")

with open("tone_bot_train.jsonl", "w", encoding="utf-8") as f:
    for ex in train:
        f.write(json.dumps(ex) + "\n")
with open("tone_bot_val.jsonl", "w", encoding="utf-8") as f:
    for ex in val:
        f.write(json.dumps(ex) + "\n")

# ---------- token estimate (tiktoken is OpenAI-only -- it does not count for Claude
# or other providers, and the per-message overhead below is an approximation, not
# the provider's exact billing formula) ----------
import tiktoken
enc = tiktoken.get_encoding("o200k_base")

def count_example_tokens(ex):
    total = 0
    for m in ex["messages"]:
        total += len(enc.encode(m["content"]))
        total += 4  # approximate per-message overhead
    return total

per_example = [count_example_tokens(ex) for ex in examples]
dataset_tokens = sum(per_example)
print(f"tiktoken o200k_base per-example -> {per_example}")
print(f"dataset total -> {dataset_tokens} tokens")
print(f"x3 epochs = {dataset_tokens * 3} training tokens")

# ---------- construct the job payload as a dict, print it, DO NOT SEND ----------
job_payload = {
    "model": "<current-fine-tunable-model-id>",  # model IDs change -- check the provider's current fine-tunable list
    "training_file": "file-<returned-by-files.create-on-train-jsonl>",
    "validation_file": "file-<returned-by-files.create-on-val-jsonl>",
    "method": {
        "type": "supervised",
        "supervised": {
            "hyperparameters": {
                "n_epochs": 3
            }
        },
    },
    "suffix": "nimbus-tone-bot",
}
print("CONSTRUCTED JOB PAYLOAD (dict, NOT sent to the API):")
print(json.dumps(job_payload, indent=2))
print("type(job_payload) ->", type(job_payload))

Fine-Tuninginterview questions & answers

10 sample questions below — 158+ in the full bank inside.

Why should you validate a fine-tuning dataset BEFORE submitting the training run?

A training job spends real compute on every single example in the file, and it does not fail gracefully mid-way if one row is malformed. Running a validator locally first is free and instant, and it catches broken JSON, wrong roles, and other structural mistakes before you've committed to a job you can't easily undo.

In simple terms: Think of it like a proofreader checking a manuscript before it goes to the printing press -- the press is expensive to run and hard to stop mid-job, so you read every page first. Example: a validator that scans a JSONL file line by line and flags 'line 240: bad JSON' takes a second to run, versus discovering the same problem after a training job has already burned through most of the file.

What format does the training file need to be in before you upload it for fine-tuning?

JSONL -- one complete JSON object per line, not a single JSON array wrapping everything. For OpenAI chat fine-tuning each line looks like `{"messages": [{"role": "system", ...}, {"role": "user", ...}, {"role": "assistant", ...}]}`, and the last message in that list must be the `assistant` turn, since that's the behaviour being learned.

In simple terms: It's like a stack of index cards instead of one giant scroll -- each card (line) is self-contained and can be read, validated, or dropped on its own, whereas a single array would need the whole thing parsed together before you know if even one entry is broken. Example: a good line is `{"messages": [{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}]}` -- and a file with 400 such lines is a valid dataset, while wrapping those same 400 objects in `[ ... ]` is not.

What does `purpose="fine-tune"` do when you call `client.files.create(...)`?

It tells the Files API what this uploaded file is for -- specifically, that it is a fine-tuning training dataset -- so the platform validates and stores it correctly for that use. The call returns a file object whose `id` you then pass into `fine_tuning.jobs.create(training_file=...)`; you never hand the job the raw file again.

In simple terms: Think of it like the form you fill at a courier counter that asks 'what's inside this package?' -- ticking 'documents' vs 'fragile' changes how it's handled downstream, even though the box looks the same from outside. Example: `client.files.create(file=open("data.jsonl", "rb"), purpose="fine-tune")` returns something like a `file.id` string, and that id -- not the file content -- is what the next call needs.

Did you actually submit a fine-tuning job? Why did the project stop where it did?

No, I stopped at constructing the job payload as a plain Python dict and printing it. Submitting a real hosted job needs an active fine-tuning eligibility and real money, and job creation is currently closed for organisations that have never fine-tuned before, so that step is out of reach for essentially anyone building this as a portfolio project.

In simple terms: It's like drafting and reviewing a contract completely, down to the exact clause wording, but never having a counterparty who's actually allowed to sign it -- the drafting is still real, verifiable work. Example: my payload used the modern method={"type": "supervised", ...} block with real argument names matched against the current SDK, but training_file and model were placeholders, and I never called client.fine_tuning.jobs.create.

Why is a saved LoRA adapter so small compared to the base model?

`model.save_pretrained(path)` only writes the LoRA adapter's own files -- `adapter_config.json` with the settings and `adapter_model.safetensors` with just the trained A/B matrices -- it does not re-save the frozen base weights at all. Since only a tiny fraction of the model's parameters are trained, that file is measured in kilobytes, well under a megabyte on a small model, against roughly 300 MB for the base model's weights.

In simple terms: You're not shipping a copy of the book, just the pad of sticky notes -- the recipient already has the book (the base model) and only needs the notes to recreate your changes. On the small GPT-2-class model we measured, the adapter came out under a megabyte while the base model's weights alone are roughly 300 MB in float32.

What state is the model in right before LoRA training starts, and why?

LoRA initialises the low-rank matrix `B` to all zeros while `A` is initialised normally, so the product `A @ B` is exactly zero before a single optimiser step runs. That means the adapted model starts as *exactly* the frozen base model -- training begins from a true no-op rather than an already-perturbed model.

In simple terms: It's like laying down blank sticky notes before you've written anything on them -- the book reads identically to the unmarked original until you actually start writing. This is one genuinely stable design fact about LoRA, not something that changes between configs or runs.

What does `lora_dropout` do in a LoRA config?

`lora_dropout` randomly zeroes out some of the adapter's activations during training, which is an ordinary regularisation trick applied specifically to the LoRA matrices rather than the whole model. It only affects training -- it's a way to reduce overfitting on the small set of trainable parameters, not a setting that changes how the adapter is scaled or applied at inference.

In simple terms: It's the same idea as regular dropout anywhere in deep learning -- randomly muting a fraction of the sticky-note ink each training step so the adapter doesn't over-rely on any single note. In the LoRA config we ran, `lora_dropout=0.1` sat alongside `r=8` and `lora_alpha=32` with no effect on those two values.

What format did you write the training data in, and why not just a JSON array?

The hosted chat fine-tuning format (this is specific to the OpenAI API; other providers and frameworks use different shapes) is JSONL -- one complete JSON object per line, each holding a "messages" list with system, user and assistant turns. I wrote each of my six examples as its own line with a trailing newline, no enclosing square brackets and no commas between examples.

In simple terms: Think of a JSON array as one sealed envelope holding every letter, versus JSONL as one open letter per line you can add to or remove without touching the rest. Example: my file's first raw line was a single object like {"messages": [...]} with a newline after it, not part of any wrapping [ ].

What is the difference between LoRA and QLoRA?

LoRA trains small low-rank adapter matrices on top of a base model whose weights sit in their normal precision. QLoRA does the same thing, but on top of a base model that has been quantized -- its weights stored in a lower-precision format such as 4-bit -- which shrinks the memory the frozen base occupies, at some quality cost, while the small trainable LoRA matrices are still trained at higher precision.

In simple terms: Quantization here is about memory, not accuracy improvement -- it is like shrinking the book itself to a smaller print run so it takes less shelf space, while the sticky notes you write on top stay full-size and legible. Ordering worth remembering: full fine-tuning needs the most memory, LoRA needs noticeably less, and QLoRA needs less again, because it stores the frozen base more compactly.

Why hold out a validation split from your fine-tuning dataset?

A held-out split is a slice of data the model never trains on, kept aside purely to measure how it performs on examples it hasn't memorised. Without it, the only signal you have about the fine-tune is how well it does on data it has already seen, which tells you nothing about how it will behave on a new, real input next week.

In simple terms: It's like a teacher setting aside a few questions the student never saw while revising, so the exam actually tests understanding instead of memorised answers. Concretely: shuffle the dataset with a fixed seed for reproducibility, then set aside roughly a fifth of the rows as `val.jsonl` and train only on the rest.

148+ more Fine-Tuning questions inside

Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.

Unlock all questions — free

Ready to practise Fine-Tuning?

Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.