Fine-Tuning with the OpenAI API¶
What You'll Learn¶
| Objective | Time | Difficulty |
|---|---|---|
| Upload training data to OpenAI | 45 min | Advanced |
| Launch and monitor a fine-tuning job | ||
| Use your fine-tuned model for inference | ||
| Evaluate and iterate on results |
Prerequisites¶
Before starting, you need:
| Requirement | Details |
|---|---|
| OpenAI API key | With fine-tuning permissions enabled |
| Training data | Validated JSONL file from Lesson 2 (50+ examples minimum) |
| Python 3.10+ | With openai package installed |
| Budget awareness | Fine-tuning costs vary by model; check current pricing |
Create a .env file:
Courses required: - Module 15, Lesson 1: When to Fine-Tune vs RAG vs Prompt Engineering - Module 15, Lesson 2: Preparing Training Data (JSONL format)
What You'll Build¶
By the end of this lesson, you will have:
- [ ] A training file uploaded to OpenAI's platform
- [ ] A completed fine-tuning job with monitored metrics
- [ ] A fine-tuned model ID ready for inference
- [ ] A side-by-side comparison script (base vs fine-tuned)
- [ ] An evaluation report with at least 10 test prompts
Example use case: Fine-tune gpt-4.1-mini on customer support conversations so it responds in your company's tone and policy.
Architecture¶
[Training Data] [Validation Data]
training_data.jsonl validation_data.jsonl
| |
v v
[OpenAI Files API] ──────────────> File IDs stored
|
v
[Fine-Tuning Job]
- Base model: gpt-4.1-mini
- Hyperparameters: n_epochs, batch_size, lr
- Suffix: custom model name
|
v
[Training Loop] (10-60 minutes)
- Training loss decreases
- Validation loss tracked
- Events logged
|
v
[Fine-Tuned Model]
ft:gpt-4.1-mini:org:suffix:id
|
v
[Inference API]
- Same chat.completions interface
- Drop-in replacement for base model
|
v
[Evaluation]
- Compare base vs fine-tuned
- Measure quality improvement
Step 1: Validate and Upload Your Training File¶
Before uploading, confirm your JSONL format is correct. Each line must be a valid chat conversation:
# validate_data.py
import json
def validate_jsonl(filepath: str) -> dict:
"""Validate training data format before upload."""
errors = []
examples = []
with open(filepath, "r") as f:
for i, line in enumerate(f, 1):
try:
example = json.loads(line.strip())
if "messages" not in example:
errors.append(f"Line {i}: missing 'messages' key")
continue
roles = [m["role"] for m in example["messages"]]
if roles[-1] != "assistant":
errors.append(f"Line {i}: last message must be from assistant")
examples.append(example)
except json.JSONDecodeError as e:
errors.append(f"Line {i}: invalid JSON - {e}")
return {
"valid": len(errors) == 0,
"total_examples": len(examples),
"errors": errors,
}
result = validate_jsonl("training_data.jsonl")
print(f"Valid: {result['valid']}, Examples: {result['total_examples']}")
if result["errors"]:
for err in result["errors"][:5]:
print(f" ERROR: {err}")
Upload once validation passes:
from openai import OpenAI
import time
client = OpenAI()
with open("training_data.jsonl", "rb") as f:
training_file = client.files.create(
file=f,
purpose="fine-tune",
)
print(f"File uploaded: {training_file.id}")
print(f"Status: {training_file.status}")
print(f"Filename: {training_file.filename}")
print(f"Bytes: {training_file.bytes}")
Upload a validation file (strongly recommended):
with open("validation_data.jsonl", "rb") as f:
validation_file = client.files.create(
file=f,
purpose="fine-tune",
)
print(f"Validation file: {validation_file.id}")
Step 2: Create a Fine-Tuning Job¶
job = client.fine_tuning.jobs.create(
training_file=training_file.id,
validation_file=validation_file.id,
model="gpt-4.1-mini",
hyperparameters={
"n_epochs": 3,
"batch_size": "auto",
"learning_rate_multiplier": "auto",
},
suffix="support-bot-v1",
)
print(f"Job created: {job.id}")
print(f"Status: {job.status}")
print(f"Base model: {job.model}")
Hyperparameters Explained¶
| Parameter | Description | Default | Guidance |
|---|---|---|---|
n_epochs |
Training passes over data | Auto (usually 3) | More epochs = risk of overfitting |
batch_size |
Examples per training step | Auto | Larger = more stable, slower |
learning_rate_multiplier |
How fast weights update | Auto | Lower = more conservative |
Rule of thumb for dataset size:
| Examples | Recommended Epochs |
|---|---|
| 50-200 | 3-4 |
| 200-1000 | 2-3 |
| 1000+ | 1-2 |
Step 3: Monitor Training Progress¶
def monitor_fine_tuning(job_id: str, poll_interval: int = 30) -> str | None:
"""Monitor a fine-tuning job until completion."""
while True:
job = client.fine_tuning.jobs.retrieve(job_id)
print(f"\nStatus: {job.status}")
if job.status == "succeeded":
print(f"Fine-tuned model: {job.fine_tuned_model}")
if job.trained_tokens:
print(f"Trained tokens: {job.trained_tokens}")
return job.fine_tuned_model
elif job.status == "failed":
print(f"Error: {job.error}")
return None
elif job.status == "cancelled":
print("Job was cancelled")
return None
events = client.fine_tuning.jobs.list_events(
fine_tuning_job_id=job_id,
limit=5,
)
for event in events.data:
print(f" [{event.created_at}] {event.message}")
print(f"Waiting {poll_interval}s...")
time.sleep(poll_interval)
fine_tuned_model = monitor_fine_tuning(job.id)
Reading Training Metrics¶
During training, OpenAI reports loss curves in the dashboard and via events:
| Metric | Healthy Pattern | Warning Sign |
|---|---|---|
| Training loss | Steady decrease | Stays flat (bad data or too few examples) |
| Validation loss | Decreases then plateaus | Increases while training loss drops (overfitting) |
| Training tokens | Matches expected count | Much lower than expected (data issues) |
If validation loss increases while training loss keeps dropping, reduce n_epochs or add more diverse training data.
Step 4: Test Your Fine-Tuned Model¶
def compare_models(
prompt: str,
system_prompt: str = "",
base_model: str = "gpt-4.1-mini",
fine_tuned_model_id: str = None,
) -> dict:
"""Compare base model vs fine-tuned model responses."""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
base_response = client.chat.completions.create(
model=base_model,
messages=messages,
temperature=0.7,
)
ft_response = client.chat.completions.create(
model=fine_tuned_model_id,
messages=messages,
temperature=0.7,
)
return {
"prompt": prompt,
"base": base_response.choices[0].message.content,
"fine_tuned": ft_response.choices[0].message.content,
"base_tokens": base_response.usage.total_tokens,
"ft_tokens": ft_response.usage.total_tokens,
}
# Test with domain-specific prompts
test_prompts = [
"What is your return policy for electronics?",
"My order hasn't arrived after 2 weeks. What should I do?",
"Can I exchange a product I bought 45 days ago?",
"Do you offer student discounts?",
"How do I cancel my subscription?",
]
for prompt in test_prompts:
result = compare_models(
prompt=prompt,
system_prompt="You are a customer service agent for TechStore.",
fine_tuned_model_id=fine_tuned_model,
)
print(f"\n--- Prompt: {prompt} ---")
print(f"BASE: {result['base'][:200]}")
print(f"FINE-TUNED: {result['fine_tuned'][:200]}")
Step 5: Evaluate and Iterate¶
Run a structured evaluation before deploying:
# evaluation/compare.py
import json
EVAL_SET = [
{
"prompt": "What is your return policy?",
"expected_keywords": ["30 days", "receipt", "refund"],
},
{
"prompt": "How do I track my order?",
"expected_keywords": ["tracking", "email", "account"],
},
]
def evaluate_model(model_id: str, eval_set: list[dict]) -> dict:
hits = 0
for case in eval_set:
response = client.chat.completions.create(
model=model_id,
messages=[{"role": "user", "content": case["prompt"]}],
temperature=0.3,
)
answer = response.choices[0].message.content.lower()
if any(kw.lower() in answer for kw in case["expected_keywords"]):
hits += 1
return {"model": model_id, "accuracy": hits / len(eval_set), "total": len(eval_set)}
base_score = evaluate_model("gpt-4.1-mini", EVAL_SET)
ft_score = evaluate_model(fine_tuned_model, EVAL_SET)
print(f"Base accuracy: {base_score['accuracy']:.0%}")
print(f"Fine-tuned accuracy: {ft_score['accuracy']:.0%}")
Iteration Decision Tree¶
Fine-tuned model worse than base?
├── Yes → Check data quality, reduce epochs, add more examples
└── No → Fine-tuned better?
├── Slightly better → Add more diverse examples, tune epochs
└── Much better → Deploy! Monitor in production.
Step 6: Manage Your Fine-Tuned Models¶
# List all fine-tuning jobs
jobs = client.fine_tuning.jobs.list(limit=10)
for j in jobs.data:
print(f"{j.id} | {j.status} | {j.fine_tuned_model or 'pending'} | {j.model}")
# Retrieve a specific job's details
job_detail = client.fine_tuning.jobs.retrieve(job.id)
print(f"Training file: {job_detail.training_file}")
print(f"Result files: {job_detail.result_files}")
# Cancel a running job
# client.fine_tuning.jobs.cancel("ftjob-abc123")
# Delete a fine-tuned model when no longer needed
# client.models.delete("ft:gpt-4.1-mini:org:support-bot-v1:abc123")
Testing Your Build¶
Verification Checklist¶
- [ ] Training file passes validation (no JSON errors, correct message format)
- [ ] Fine-tuning job reaches
succeededstatus - [ ] Fine-tuned model responds to inference calls
- [ ] Fine-tuned responses differ meaningfully from base model
- [ ] Evaluation accuracy is higher on domain-specific prompts
- [ ] Validation loss did not diverge from training loss
Common Issues and Solutions¶
| Issue | Cause | Solution |
|---|---|---|
| Job fails immediately | Invalid data format | Run validate_jsonl() from Step 1 |
| High training loss | Too few examples or bad data | Add 50+ quality examples |
| Validation loss increases | Overfitting | Reduce epochs to 1-2, add more data |
| Model ignores fine-tuning | System prompt conflicts | Use minimal or no system prompt during inference |
| Slow training | Large dataset or model | Expected — GPT-4.1 takes 30-60 min |
| Identical outputs | Too similar training examples | Increase diversity in training data |
Deployment Notes¶
Using Fine-Tuned Models in Production¶
Fine-tuned models use the same API interface as base models — swap the model ID:
# In your production app
MODEL_ID = "ft:gpt-4.1-mini:org:support-bot-v1:abc123" # or base model as fallback
response = client.chat.completions.create(
model=MODEL_ID,
messages=messages,
temperature=0.3, # Lower temperature for consistent support responses
)
Production Checklist¶
- [ ] Store fine-tuned model ID in environment variables (not hardcoded)
- [ ] Implement fallback to base model if fine-tuned model is unavailable
- [ ] Monitor response quality weekly with an eval set
- [ ] Re-fine-tune when accuracy drops or policies change
- [ ] Track cost: fine-tuned models have different per-token pricing
Cost Considerations¶
| Cost Type | When Charged |
|---|---|
| Training | Per token trained (one-time per job) |
| Inference | Per token input + output (ongoing) |
| Storage | Model stored until you delete it |
Fine-tuning pays off when you need consistent style/format and can reduce prompt length (fewer tokens per request).
Extensions and Challenges¶
- Multi-turn fine-tuning: Include full conversation histories, not just single Q&A pairs
- DPO fine-tuning: Use Direct Preference Optimization with chosen/rejected response pairs
- A/B testing: Route 50% of traffic to fine-tuned model, compare satisfaction scores
- Continuous fine-tuning: Append new high-quality conversations monthly and re-train
- Function calling fine-tune: Fine-tune on examples that include tool use patterns
Key Takeaways¶
- Fine-tuning on OpenAI is a 4-step process: upload data, create job, monitor, test
- Always use a validation file to detect overfitting early
- Start with default hyperparameters — they work well for most use cases
- Compare fine-tuned vs base model outputs to verify improvement
- Training typically takes 10-60 minutes depending on dataset size
- Fine-tuned models are drop-in replacements — same API, different model ID
Next Lesson¶
Lesson 4: Fine-Tuning Open-Source Models with Hugging Face — Learn to fine-tune Llama, Mistral, and other open-source models using the Hugging Face ecosystem.