A practical, end-to-end walkthrough of fine-tuning a small open-weight LLM on a custom dataset using Amazon SageMaker - from raw data to a deployed endpoint you can call from a web UI.
What you'll build: a specialised model that takes a free-text restaurant review and returns structured JSON (cuisine, rating, price range, highlights, sentiment, recommendation).
Techniques covered:
- Synthetic dataset creation with Claude (LLM-as-annotator)
- QLoRA fine-tuning on a quantised base model
- SageMaker training jobs and HuggingFace endpoints
- Custom inference handler (
model_fn/predict_fn) for chat-style I/O
- AWS account with SageMaker access
- IAM role for SageMaker (see setup below)
- HuggingFace token with read access to
Qwen/Qwen2.5-3B-Instruct - Anthropic API key (used once to annotate ~500 reviews)
- Python 3.12+ and
uv - GPU quota for
ml.g5.2xlarge(training) andml.g5.xlarge(inference) in your chosen region
Create a SageMaker execution role with the AmazonSageMakerFullAccess managed
policy attached. Default name in this tutorial is KamendSageMakerExecutionRole
- change
ROLE_NAMEin.envto match yours.
| Step | Instance | Duration | Approx. cost |
|---|---|---|---|
| Training | ml.g5.2xlarge |
~30 min | ~$0.65 |
| Endpoint | ml.g5.xlarge |
per hour | ~$1.00/hr |
| Claude annotation | API (Haiku) | ~500 calls | <$0.50 |
The endpoint keeps billing until you delete it - see Cleanup.
fine_tunning_tutorial/
├── config.py # shared config (reads from .env)
├── .env.example # copy to .env and fill in
├── pyproject.toml
│
├── data/ # Stage 1 - dataset prep
│ ├── download_reviews.py Yelp sampler
│ ├── annotate_reviews.py Claude annotator + splitter
│ └── schema.json Output JSON schema
│
├── training/ # Stage 2 - train on SageMaker
│ ├── launch.py Local launcher (uploads data, starts job)
│ └── sagemaker/ Runs inside the training container
│ ├── train.py
│ ├── inference.py
│ └── requirements.txt
│
├── deployment/ # Stage 3 - deploy endpoint
│ └── deploy.py
│
└── webapp/ # Stage 4 - Flask UI for testing
├── app.py
└── templates/index.html
Run scripts as modules from the project root, e.g.
python -m training.launch. That way from config import X resolves naturally
without any sys.path tricks.
# 1. Install dependencies
uv sync
# 2. Configure secrets and settings
cp .env.example .env
# Edit .env - at minimum set HF_TOKEN and ANTHROPIC_API_KEY
# 3. Verify AWS credentials
aws sts get-caller-identityAll tunable values (region, role name, model ID, instance types, endpoint
name) live in .env. The config.py file just loads them with sensible
defaults.
Goal: produce train.jsonl / valid.jsonl / test.jsonl in chat-messages
format, where each example is a review paired with the structured JSON the
model should learn to emit.
python -m data.download_reviewsPulls the Yelp review dataset
from HuggingFace, filters for restaurant-related reviews by keyword matching,
and samples ~500 examples balanced across 1–5 star ratings. Writes
data/raw_reviews.jsonl.
python -m data.annotate_reviewsSends each raw review to Claude Haiku with an extraction prompt, validates the
returned JSON against data/schema.json, and produces training examples in
chat format:
{
"messages": [
{"role": "system", "content": "You are a restaurant review analyzer..."},
{"role": "user", "content": "<review text>"},
{"role": "assistant", "content": "{\"cuisine\": \"Italian\", ...}"}
]
}This is LLM-as-annotator: we use a strong general model to create training data so a smaller specialised model can learn the same task cheaper and faster.
The script is resumable - if you stop it mid-run, it picks up where it
left off via annotated_reviews.jsonl. At the end it shuffles and splits 80/10/10
into train.jsonl, valid.jsonl, test.jsonl.
Goal: fine-tune Qwen/Qwen2.5-3B-Instruct on your annotated data using
QLoRA, then merge the adapter into the base model weights so the result is a
drop-in replacement for the base model.
python -m training.launch- Uploads
data/train.jsonlanddata/valid.jsonlto the default SageMaker S3 bucket. - Constructs a
HuggingFaceestimator pointing attraining/sagemaker/as the source directory. - Calls
estimator.fit(...), which provisions aml.g5.2xlargeinstance, pulls the HF training container, installs the extra deps fromsagemaker/requirements.txt, and runssagemaker/train.py. - Streams logs back to your terminal. When it finishes, it prints the S3 URI
of
model.tar.gz- save this URI, you'll need it for deployment.
- Loads the base model in 4-bit (
BitsAndBytesConfig+ NF4 quantisation). - Attaches a LoRA adapter with
r=16, targeting all attention and MLP projections. - Uses
SFTTrainerwithDataCollatorForCompletionOnlyLMso loss is computed only on assistant response tokens, not the prompt. - After training, reloads the base model in fp16, merges the LoRA adapter, and saves the full merged model.
- Copies
inference.pyand a minimalrequirements.txtintomodel/code/- SageMaker picks these up as the custom inference handler. - SageMaker tars everything in
/opt/ml/model/intomodel.tar.gzand uploads it to S3.
Why merge the adapter? A merged fp16 model loads faster at inference time and needs no PEFT dependency in the inference container.
Hyperparameters live in training/launch.py (not .env - they're
experiment-specific):
hyperparameters = {
"epochs": 3,
"batch_size": 4,
"gradient_accumulation_steps": 4,
"learning_rate": 2e-4,
"max_seq_length": 1024,
"lora_r": 16,
"lora_alpha": 32,
"lora_dropout": 0.05,
"warmup_ratio": 0.1,
}Goal: stand up a real-time HTTPS endpoint backed by a ml.g5.xlarge GPU
instance, using the inference.py handler bundled with your model artifact.
python -m deployment.deploy --model-data s3://<bucket>/.../model.tar.gzThe --model-data URI is what launch.py printed at the end of training.
training/sagemaker/inference.py implements the four SageMaker hooks:
model_fn- loads the merged model + tokenizer once at container startupinput_fn- parses the JSON request bodypredict_fn- applies the chat template, runsmodel.generate(...), strips the prompt tokens, returns the generated textoutput_fn- serialises the response to JSON
The endpoint accepts either {"messages": [...]} (chat format) or
{"inputs": "<prompt>"} (raw prompt), plus optional parameters
(max_new_tokens, temperature).
Deployment takes ~5–10 minutes. When it completes, the endpoint is named
ENDPOINT_NAME from your .env (default: qwen-3b-review).
python -m webapp.appOpens a Flask dev server on http://localhost:5001. Paste a review, hit Analyze, and the UI shows both the parsed structured output and the raw model response.
Under the hood, app.py calls the SageMaker runtime client, parses the
response (handling markdown fences / preamble text the model might emit), and
renders it.
Endpoints bill by the hour. Delete yours when you're done.
aws sagemaker delete-endpoint --endpoint-name qwen-3b-review
aws sagemaker delete-endpoint-config --endpoint-config-name qwen-3b-review
aws sagemaker delete-model --model-name <model-name>List what you have:
aws sagemaker list-endpointsAll settings in .env (see .env.example for the full list):
| Variable | Purpose |
|---|---|
HF_TOKEN |
Pulls the base model from HuggingFace during training |
ANTHROPIC_API_KEY |
Used by annotate_reviews.py |
REGION |
AWS region for all SageMaker resources |
ROLE_NAME |
SageMaker execution role |
MODEL_ID |
Base model (any causal-LM compatible with HF Transformers works) |
TRAINING_INSTANCE |
Training instance type |
INFERENCE_INSTANCE |
Endpoint instance type |
ENDPOINT_NAME |
Name of the deployed endpoint |
S3_PREFIX |
Prefix under the default SageMaker bucket |
TRANSFORMERS_VERSION, *_PYTORCH_VERSION, *_PY_VERSION |
Pin the HuggingFace container versions |