Fine-Tune a Fantasy Football Analyst

Train a LoRA adapter that turns Qwen3-8B into a fantasy football expert.

Introduction

30 Second Summary

Fantasy football managers drown in NFL news every week. Injury reports, depth chart changes, trade announcements. You need quick, decisive calls, not rambling analysis that hedges with "it depends."

In this project, you will fine-tune an open-source LLM to become an opinionated fantasy football analyst that takes raw NFL news and returns structured JSON recommendations with a clear action, confidence level, and reasoning.

What You'll Build

Paste an NFL news blurb into your model and get back a clean JSON object telling you exactly what to do with the affected player, complete with a confidence rating and the fantasy-relevant logic behind the call.

By the end of this project, you'll have:

  • A trained LoRA adapter that transforms Qwen3-8B into a fantasy football expert, committing to specific start/sit/add/drop/hold calls instead of hedging.
  • A documented before-and-after comparison proving the base model rambles and hedges while your fine-tuned version delivers actionable, structured analysis.
  • Hands-on experience with the full cloud GPU lifecycle on Vast.ai: rent, connect, train, download, destroy.
  • Secret Mission: Test your model's generalization by feeding it novel news patterns it never saw in training and evaluating whether the fantasy reasoning transfers.

Are there any prerequisites?

This project assumes you already have a Vast.ai account with credit loaded, an SSH key pair configured, and the Vast.ai CLI installed and authenticated.

You should also be comfortable working in a terminal. All training dependencies are pre-installed on the remote GPU instance via the Unsloth Docker image, so no local GPU or Python package setup is needed.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Fine-tuning teaches a model domain expertise it cannot pick up from a generic prompt. Naming your target behavior upfront keeps every decision in this project focused.

Rent a GPU and Connect

Qwen3-8B has 8.2 billion parameters. Loading it into memory and running training requires a GPU with at least 24GB of VRAM.

Your local machine almost certainly does not have this. In this step, you will rent a GPU from Vast.ai's marketplace, connect over SSH, and confirm the training stack is ready to go.

In this step, get ready to:
  • Search for and rent a 24GB+ GPU instance on Vast.ai.
  • SSH into the remote machine.
  • Verify the GPU and training stack are ready.
Search for a GPU instance and deploy the Unsloth Docker image

Vast.ai is a GPU marketplace where independent hosts rent out their hardware by the hour. You search for machines that meet your specs, then deploy a Docker image onto one.

  • Search for available 24GB+ GPU instances by running this command:
vastai search offers 'gpu_ram >= 24 num_gpus = 1 cuda_vers >= 12.4 disk_space >= 50 reliability > 0.98 inet_down >= 5000' --order 'dph_base' --limit 5

What does this command do?

This searches the Vast.ai marketplace for machines with at least 24GB GPU RAM, CUDA 12.4+, 50GB disk, high reliability, and decent download speed. Results are sorted by price (cheapest first) and limited to 5.

You should see a table of available offers with columns including ID, GPU name, and cost per hour.

  • Note the ID value from the cheapest offer in the results (the first row). This is your offer ID.
  • Deploy the Unsloth Docker image onto that instance by running:
vastai create instance [[OFFER_ID="your offer ID from the search results"]] --image unsloth/unsloth --disk 50 --ssh --direct

What does this command do?

This rents the GPU you selected, deploys the unsloth/unsloth Docker image (which has PyTorch, CUDA, Unsloth, and all training dependencies pre-installed), allocates 50GB of disk, and enables direct SSH access.

You should see a JSON response with "success": true and a new_contract value. That new_contract number is your instance ID. Save it.

Seeing an error about insufficient funds?

Make sure your Vast.ai account has credit loaded. 24GB GPU instances typically cost $0.30-0.50 per hour. You can add credit at vast.ai/billing.

If no offers matched, relax the reliability filter to 0.95 or remove inet_down and try again.

Still stuck? Help me troubleshoot my Vast.ai instance creation

Wait for running status and get SSH connection details

After creation, the instance takes 1-3 minutes to download the Docker image and boot. You need to wait until its status shows running before you can connect.

  • Check your instance status by running:
vastai show instances

Look at the actual_status column. If it does not yet say running, wait 30 seconds and run the command again.

  • Once your instance shows running, get the SSH connection details by running:
vastai ssh-url [[INSTANCE_ID="your instance ID from the create step"]]

You should see output in the format ssh://root@<host>:<port>. This tells you the host and port to connect to.

Instance stuck in loading status?

Some instances take up to 5 minutes to pull the Docker image. Keep polling with vastai show instances every 30 seconds.

If the status shows an error or the instance never starts, destroy it with vastai destroy instance and create a new one from the next cheapest offer.

Need help? My Vast.ai instance is stuck and won't reach running status

SSH into the instance and verify the GPU and Unsloth are ready

Now that the instance is running, you will connect and confirm the hardware and software stack are ready for training.

  • Connect to your instance using the host and port from the previous output:
ssh -p [[SSH_PORT="port from ssh-url output"]] root@[[SSH_HOST="host from ssh-url output"]]

What if it asks about a fingerprint?

The first time you connect to a new host, SSH asks you to confirm the fingerprint. Type yes and press Enter to continue.

You should now see a root shell prompt on the remote machine.

Before running nvidia-smi, what do you expect to see? Think about what GPU specs you filtered for in your search.

  • Verify the GPU is visible by running:
nvidia-smi

You should see a table showing your GPU with 24GB+ of memory. The exact model (RTX 3090, RTX 4090, A5000, etc.) depends on which offer you rented.

  • Verify the Unsloth training stack is installed by running:
python -c "import unsloth; print('Unsloth ready')"

You should see Unsloth ready printed. This confirms PyTorch, CUDA, and all training dependencies are available inside the container.

Seeing 'ModuleNotFoundError: No module named unsloth'?

This means the Docker image did not load correctly. Confirm you used unsloth/unsloth (not a different image) when creating the instance.

If you used the wrong image, destroy this instance and create a new one with the correct --image unsloth/unsloth flag.

Still stuck? Unsloth is not found on my Vast.ai instance even though I used the right image

Your cloud GPU is live and the training stack is loaded. Next up, you will test the base model on a fantasy football prompt and see exactly how it falls short.

Test the Base Model with Fantasy News

Your GPU is live and Unsloth is ready. Now you need to see what the base model actually does when you throw fantasy football news at it.

Before teaching the model anything, you will test how Qwen3-8B handles a fantasy football analysis task out of the box. The base model will produce a verbose, wishy-washy response that hedges instead of committing to a recommendation. This gap is exactly what fine-tuning will close in the next step.

In this step, get ready to:
  • Write a Python script that loads Qwen3-8B in 4-bit and sends it a fantasy football news prompt.
  • Run the script and document how the base model responds to structured analysis requests.
Write the base model test script

You will create a script that loads the 8.2 billion parameter model in 4-bit quantization and asks it to analyze an NFL injury report. The prompt explicitly requests structured JSON output with specific keys.

  • Create test_base_model.py on the remote instance by running:
nano test_base_model.py
  • Paste the following model-loading code into the empty file:
from unsloth import FastLanguageModel
import torch

max_seq_length = 2048
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Qwen3-8B-bnb-4bit",
    max_seq_length=max_seq_length,
    load_in_4bit=True,
)

FastLanguageModel.for_inference(model)

What does this code do?

FastLanguageModel.from_pretrained downloads Qwen3-8B from Hugging Face and loads it in 4-bit quantization. This compresses the model's weights so it fits in your 24GB GPU memory.

FastLanguageModel.for_inference(model) switches the model into generation mode. Without this call, inference runs slower.

  • Below the model-loading code, paste the inference logic:
messages = [
    {"role": "user", "content": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Chiefs HC Andy Reid confirms Patrick Mahomes is questionable for Sunday with an ankle sprain suffered in practice. Backup Carson Wentz would start if Mahomes cannot go."}
]

text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=256,
        temperature=0.7,
        top_p=0.8,
    )

response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
print("=== BASE MODEL RESPONSE ===")
print(response)

What does this code do?

The messages list uses Qwen3's chat template format. The prompt explicitly asks for a JSON object with four keys: player, action, confidence, and reasoning.

apply_chat_template wraps the message in the special tokens Qwen3 expects. The model generates up to 256 tokens with temperature=0.7 for slight randomness.

The final decode strips out the input prompt so you only see the model's response printed under the === BASE MODEL RESPONSE === header.

  • Save and exit nano by pressing **Ctrl+X**, then **Y**, then **Enter**.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

Your test_base_model.py should contain exactly this:

from unsloth import FastLanguageModel
import torch

max_seq_length = 2048
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Qwen3-8B-bnb-4bit",
    max_seq_length=max_seq_length,
    load_in_4bit=True,
)

FastLanguageModel.for_inference(model)

messages = [
    {"role": "user", "content": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Chiefs HC Andy Reid confirms Patrick Mahomes is questionable for Sunday with an ankle sprain suffered in practice. Backup Carson Wentz would start if Mahomes cannot go."}
]

text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=256,
        temperature=0.7,
        top_p=0.8,
    )

response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
print("=== BASE MODEL RESPONSE ===")
print(response)
Run the script and document the baseline response

Now you will see how the untrained model responds to a fantasy football analysis request. Before you run the script, think about what you expect.

The prompt explicitly asks for a JSON object and says "Return ONLY the JSON object, no other text." Do you think the base model will comply?

  • Run the test script:
python test_base_model.py

The model takes a minute or two to load into GPU memory, then generates its response. Look at what appears under the === BASE MODEL RESPONSE === header.

What should I see?

The base model will NOT return clean JSON. Instead, you will see one or more of these behaviors:

  • A rambling paragraph discussing multiple scenarios instead of committing to one action.
  • Hedging language like "it depends" or "fantasy managers should consider" without a clear recommendation.
  • JSON wrapped in markdown code fences with extra commentary above and below it.
  • A refusal to commit, offering both "start" and "sit" as possibilities.

This is the gap. The model knows English and knows what JSON looks like. But it has no fantasy football domain expertise telling it to commit to a specific call. That is exactly what your training data will teach it in the next step.

🙋‍♀️ Script erroring out?

  • If you see an out-of-memory error, confirm your GPU has 24GB+ VRAM by running nvidia-smi. If it shows less, you rented an instance with insufficient VRAM.
  • If you see ModuleNotFoundError: No module named 'unsloth', you may have SSH'd into the wrong instance or the Docker image did not load correctly. Destroy and re-create the instance with the unsloth/unsloth image.

Help me debug my test_base_model.py script on Vast.ai

You now have documented proof that the base model cannot produce clean, committed fantasy analysis. Next up, you will write training examples that teach the model exactly how a fantasy football analyst reasons and responds.

Write and Run the Training Script

You saw the problem in Step 2: Qwen3-8B hedges, rambles, and refuses to commit to a fantasy football recommendation. The model was never taught to think like an analyst who reads injury reports, evaluates depth chart changes, and makes decisive calls.

In this step, you will write 10 supervised training examples that encode fantasy football expertise. QLoRA lets you train tiny adapter weights (under 1% of the model's 8.2 billion parameters) while the frozen base stays intact.

In this step, get ready to:
  • Write a training script with 10 fantasy football examples covering injuries, trades, suspensions, and weather.
  • Run training and watch the loss curve decrease.
  • Confirm the LoRA adapter saved successfully.
Write the training script with fantasy football examples

The training script has four responsibilities: load the model, define training data, configure the trainer, and save the adapter. You will create the full file in nano and then run it.

  • Create the training script by running:
nano train.py

Paste the following code sections into the file, one after another.

  • Start by pasting the imports, model loading, and LoRA configuration:
from unsloth import FastLanguageModel
import torch
from trl import SFTTrainer, SFTConfig
from datasets import Dataset

max_seq_length = 2048

# Load model in 4-bit
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Qwen3-8B-bnb-4bit",
    max_seq_length=max_seq_length,
    load_in_4bit=True,
)

# Configure LoRA adapters on all linear layers
model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_alpha=16,
    lora_dropout=0,
    bias="none",
    use_gradient_checkpointing="unsloth",
    random_state=3407,
)

What does this code do?

This section sets up a trainable model with minimal memory footprint.

  • The model loads in 4-bit quantization, shrinking memory usage so it fits on a 24GB GPU alongside training overhead.
  • get_peft_model attaches LoRA adapters with rank 16 to all attention and feedforward projections.
  • Only these tiny adapter matrices will be updated during training. The 8.2 billion base parameters stay frozen.
  • Below the LoRA config, paste the training examples array:
# Fantasy football training examples
training_examples = [
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Cowboys RB Tony Pollard has been ruled out for Week 12 with a high ankle sprain. Backup Rico Dowdle will handle the lead role against the Commanders.", "output": '{"player": "Rico Dowdle", "action": "add", "confidence": "high", "reasoning": "Dowdle inherits a full workload in a plus matchup against Washington. High-ankle sprains typically sideline players 2-4 weeks, giving Dowdle standalone value."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Jaguars WR Christian Kirk returned to full practice today after missing three weeks with a groin injury. He is expected to play Sunday against the Titans.", "output": '{"player": "Christian Kirk", "action": "start", "confidence": "medium", "reasoning": "Kirk returns to a pass-heavy offense that struggled without him. Medium confidence due to potential snap count limitation in his first game back."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: The Bears have traded WR DJ Moore to the Ravens for a 2025 second-round pick. Lamar Jackson now has a true WR1 target.", "output": '{"player": "DJ Moore", "action": "start", "confidence": "high", "reasoning": "Moore joins the most efficient offense in football and immediately becomes Lamar Jackson\'s top target. His target volume should remain elite with a massive upgrade at QB."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Dolphins WR Tyreek Hill posted a cryptic social media message hinting at frustration with the coaching staff. HC Mike McDaniel says Hill will play Sunday but declined to guarantee his target share.", "output": '{"player": "Tyreek Hill", "action": "hold", "confidence": "low", "reasoning": "Hill\'s talent makes him undroppable but the situation clouds his short-term ceiling. Monitor practice reports and target trends before making lineup decisions."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Packers TE Luke Musgrave has been suspended two games for violating the NFL's personal conduct policy. TE Tucker Kraft will assume the starting role.", "output": '{"player": "Tucker Kraft", "action": "add", "confidence": "medium", "reasoning": "Kraft steps into a high-value role in Green Bay\'s offense for at least two weeks. His athletic profile and prior chemistry with Love make him a strong streaming option at a thin position."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Forecast calls for 25+ mph winds and heavy rain in Buffalo for Sunday's Bills vs Patriots game. Josh Allen has historically struggled in extreme weather, averaging 15 fewer passing yards per game in winds over 20 mph.", "output": '{"player": "Josh Allen", "action": "sit", "confidence": "medium", "reasoning": "Extreme wind conditions cap Allen\'s passing ceiling significantly. Consider a QB with a dome or favorable forecast if you have a viable alternative on your bench."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: 49ers RB Christian McCaffrey was a full participant in practice all week and has no injury designation for the first time in six weeks. He faces the Cardinals who rank 31st against the run.", "output": '{"player": "Christian McCaffrey", "action": "start", "confidence": "high", "reasoning": "CMC is fully healthy against the worst run defense in football. This is a smash spot with elite volume and efficiency upside."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Rookie QB Caleb Williams threw for 380 yards and 4 TDs against the Vikings secondary. Bears OC Shane Waldron says the offense will continue pushing the ball downfield.", "output": '{"player": "Caleb Williams", "action": "add", "confidence": "medium", "reasoning": "Williams showed elite arm talent and the coaching staff is signaling an aggressive scheme going forward. The breakout may be sustainable given his weapons, but one game is a small sample."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Bengals WR Ja'Marr Chase is dealing with a hamstring issue and was limited in practice Wednesday. HC Zac Taylor says it's precautionary and expects Chase to play.", "output": '{"player": "Ja\'Marr Chase", "action": "start", "confidence": "medium", "reasoning": "Wednesday limitations for stars are often precautionary rest. Chase\'s target volume makes him a must-start, but monitor Thursday and Friday practice reports for any downgrade."}'},
    {"input": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Saints have released veteran RB Alvin Kamara following a failed physical. Second-year back Kendre Miller is named the starter going forward.", "output": '{"player": "Kendre Miller", "action": "add", "confidence": "high", "reasoning": "Miller inherits a three-down role in an offense that has always funneled volume to the lead back. His draft capital and offseason buzz make him a league-winning waiver add."}'},
]

What do these examples teach?

Each example pairs an NFL news blurb (the user message) with the exact JSON analysis you want the model to produce (the assistant message).

  • The 10 examples cover 6 distinct scenario types: injury reports, depth chart changes, trades, suspensions, weather impacts, and breakout performances.
  • Every output commits to a specific action (start/sit/add/drop/hold) with reasoning tied to fantasy-relevant signals.
  • This diversity teaches the model that different news types require different reasoning patterns.
  • Below the training examples, paste the dataset formatting function:
# Format as chat conversations
def format_example(example):
    messages = [
        {"role": "user", "content": example["input"]},
        {"role": "assistant", "content": example["output"]},
    ]
    return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}

dataset = Dataset.from_list(training_examples)
dataset = dataset.map(format_example)

What does this code do?

This wraps each input/output pair into the chat template format that Qwen3-8B expects. apply_chat_template adds the special tokens (like <|im_start|> and <|im_end|>) so the model learns which text is the user query and which is the desired response.

  • Finally, paste the trainer configuration, execution, and save logic:
# Configure and run training
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    args=SFTConfig(
        max_seq_length=max_seq_length,
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        num_train_epochs=3,
        learning_rate=2e-4,
        logging_steps=1,
        output_dir="outputs",
        optim="adamw_8bit",
        weight_decay=0.01,
        lr_scheduler_type="linear",
        seed=3407,
    ),
)

print("Starting training...")
trainer_stats = trainer.train()
print(f"\nTraining complete! Final loss: {trainer_stats.training_loss:.4f}")

# Save the adapter
model.save_pretrained("outputs/qwen3-8b-ff-analyst")
tokenizer.save_pretrained("outputs/qwen3-8b-ff-analyst")
print("Adapter saved to outputs/qwen3-8b-ff-analyst/")

What does this code do?

SFTTrainer runs supervised fine-tuning using your 10 examples.

  • Batch size 2 with gradient accumulation 4 means an effective batch of 8 examples per optimizer step.
  • 3 epochs means the model sees every example 3 times, reinforcing the pattern.
  • adamw_8bit optimizer saves GPU memory by keeping optimizer state in 8-bit precision.
  • After training finishes, the adapter weights are saved to outputs/qwen3-8b-ff-analyst/ as a portable file you can load later.
  • Save and exit nano by pressing Ctrl+X, then Y, then Enter.
  • Confirm the file exists by running:
ls train.py

You should see train.py listed in the output.

Don't see train.py?

  • Make sure you pressed Y when nano asked to save. If you pressed N, the file was discarded.
  • Re-run nano train.py and paste all four sections again.

Help me troubleshoot my train.py file not saving in nano.

✔️ Awesome, I've got everything!

Great. Double check you saved the file, then move on to running it.

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains all four sections in order:

  • Imports, model loading, and LoRA configuration (starting with from unsloth import FastLanguageModel).
  • The training_examples array with 10 dictionary entries.
  • The format_example function and two dataset lines.
  • The SFTTrainer block, trainer.train(), and model.save_pretrained calls.

You can also view the file in your terminal by running cat train.py and comparing against the sections above.

Run training and monitor the loss curve

The script will load the model, attach the adapters, format your examples, and run 3 epochs of training. This takes approximately 15-20 minutes on a 24GB GPU.

  • Before you run training, predict: what do you think the initial loss value will look like, and what direction should it move?
  • Start training by running:
python train.py

You will see logging output after each optimizer step, showing the loss value. The loss should start around 2.0 or higher and steadily decrease over the training run.

What is training loss telling you?

Loss measures how far the model's predictions are from your training examples. A high loss means the model is still producing outputs that look nothing like your desired JSON format.

As loss decreases, the model is learning to reproduce the structured, committed recommendations you defined. The final loss should drop below 1.0, which means the model's outputs closely match your examples.

When training finishes, you should see output like:

Training complete! Final loss: 0.XXXX
Adapter saved to outputs/qwen3-8b-ff-analyst/

Seeing an out of memory error?

  • This means your GPU ran out of VRAM. Make sure you are not running any other Python processes that hold GPU memory.
  • Run nvidia-smi to check if another process is using your GPU. Kill it with kill -9 <PID> and try again.

Help me fix an out of memory error during QLoRA training on Vast.ai.

Confirm the adapter was saved
  • Verify the adapter files exist by running:
ls -lh outputs/qwen3-8b-ff-analyst/

You should see adapter_model.safetensors (approximately 100MB) and adapter_config.json in the listing.

Why is the adapter so small?

The full Qwen3-8B model is over 16GB. Your adapter is only ~100MB because LoRA trains just the low-rank update matrices, not the full model weights.

This portability is the whole point. You can ship this 100MB file to any machine, load the base model, attach the adapter, and get your fantasy analyst behavior back instantly.

🙋‍♀️ Directory not found?

  • If training errored out before saving, the directory will not exist. Scroll up in your terminal to find the error message.
  • If training completed but the directory is empty, check you did not modify the save_pretrained path in the script.

Help me figure out why my LoRA adapter directory is missing after training.

Your adapter is trained and saved. Next up, you will load it back onto the base model and run the exact same Mahomes prompt from Step 2 to see whether the fine-tuning actually changed the model's behavior.

Test the Fine-Tuned Model and Clean Up

Your adapter is trained and saved, but numbers on a loss curve are not proof of behavior change. You need to see the model respond differently to the exact same prompt.

By running the identical Mahomes injury prompt from Step 2 with your adapter loaded, you isolate the single variable: same model, same prompt, same generation settings. Any difference in the output is directly attributable to your 10 training examples.

In this step, get ready to:
  • Run inference with the LoRA adapter loaded and compare to the base model's response.
  • Download the adapter to your local machine and destroy the Vast.ai instance.
Create the inference script with adapter loading

You will create a script that loads the same base model, attaches your trained adapter on top, and runs the Mahomes prompt. The key difference from test_base_model.py is a single line: PeftModel.from_pretrained layering your adapter weights onto the frozen model.

  • Create test_finetuned.py on the remote instance by running:
nano test_finetuned.py
  • Paste the following code that loads the base model and attaches your trained LoRA adapter:
from unsloth import FastLanguageModel
import torch
from peft import PeftModel

max_seq_length = 2048
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Qwen3-8B-bnb-4bit",
    max_seq_length=max_seq_length,
    load_in_4bit=True,
)

# Load the fine-tuned adapter
model = PeftModel.from_pretrained(model, "outputs/qwen3-8b-ff-analyst")

FastLanguageModel.for_inference(model)

What does this code do?

  • It loads the same Qwen3-8B base model in 4-bit, identical to test_base_model.py.
  • PeftModel.from_pretrained layers your trained adapter weights on top of the frozen base model. This is what makes the output different without changing the underlying 8.2 billion parameters.
  • FastLanguageModel.for_inference switches the model to optimized inference mode (no gradient tracking).
  • Below that code, paste the inference section that runs the same Mahomes prompt from Step 2:
# Test with the SAME prompt used in the base model test
messages = [
    {"role": "user", "content": "You are a fantasy football analyst. Analyze the following NFL news and return a JSON object with keys 'player', 'action' (one of: start, sit, add, drop, hold), 'confidence' (one of: high, medium, low), and 'reasoning' (1-2 sentences). Return ONLY the JSON object, no other text.\n\nNews: Chiefs HC Andy Reid confirms Patrick Mahomes is questionable for Sunday with an ankle sprain suffered in practice. Backup Carson Wentz would start if Mahomes cannot go."}
]

text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=256,
        temperature=0.7,
        top_p=0.8,
    )

response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
print("=== FINE-TUNED MODEL RESPONSE ===")
print(response)
print("\n=== EXPECTED FORMAT ===")
print('{"player": "Patrick Mahomes", "action": "sit", "confidence": "medium", "reasoning": "Ankle sprains limit mobility and Mahomes may not play at all. If active, he carries significant injury risk that caps his ceiling."}')

What does this code do?

  • The messages list uses the exact same prompt you tested against the base model in Step 2. This is the controlled variable that lets you isolate the adapter's effect.
  • apply_chat_template formats the prompt using Qwen3's expected chat structure, and add_generation_prompt=True signals the model to start generating.
  • The script prints both the model's actual response and an example of the expected JSON format so you can compare structure.
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before exiting nano.

ⓧ I'd like to double check the full code

Scroll up and confirm your file has both chunks in order:

  • Chunk 1: Imports, model loading with FastLanguageModel.from_pretrained, adapter loading with PeftModel.from_pretrained, and for_inference call.
  • Chunk 2: The messages list with the Mahomes prompt, apply_chat_template, model.generate, and three print statements.
Run the script and compare to baseline

This is the moment of truth. You are about to see whether 10 training examples were enough to teach the model structured fantasy football reasoning.

  • Before you run this, think back to the base model's response from Step 2. Do you think the fine-tuned model will return clean JSON, or will it still hedge and ramble?
  • Run the inference script:
python test_finetuned.py

Under the === FINE-TUNED MODEL RESPONSE === header, you should see clean, structured JSON with a committed fantasy recommendation. Compare this to the base model's verbose, non-committal output from Step 2.

What should I see?

The fine-tuned model should return something close to this structure:

{"player": "Patrick Mahomes", "action": "sit", "confidence": "medium", "reasoning": "Ankle sprains limit mobility and Mahomes may not play at all. If active, he carries significant injury risk that caps his ceiling."}

The exact wording may vary slightly due to temperature sampling, but the response should be a single JSON object with all four keys populated. No hedging paragraphs, no markdown fences, no "it depends" qualifiers.

🙋‍♀️ Still seeing unstructured output?

  • Check that the adapter path in your script matches exactly: outputs/qwen3-8b-ff-analyst. A typo here means the adapter never loads and you get base model behavior.
  • Confirm your training loss dropped below 1.0. If it stayed above 2.0, the model did not learn the pattern and you may need to re-run training.
  • Make sure from peft import PeftModel is at the top of your script. A missing import would cause an error, not bad output, but double-check.

Help me debug my fine-tuned model output

Download the adapter and destroy the instance

Your adapter is proven to work. Now you need to get it off the rented GPU and onto your local machine before you stop paying for the instance.

  • Exit the SSH session by running:
exit
  • You are now back in your local terminal. Download the adapter directory from the remote instance by running:
scp -P [[SSH_PORT="port from your ssh-url output"]] -r root@[[SSH_HOST="hostname from your ssh-url output"]]:outputs/qwen3-8b-ff-analyst/ ./outputs/qwen3-8b-ff-analyst/

What does this command do?

  • scp securely copies files over SSH. The -P flag specifies the port and -r copies the entire directory recursively.
  • Use the same host and port from the vastai ssh-url output you used to connect in Step 1.
  • Confirm the adapter downloaded successfully by running:
ls outputs/qwen3-8b-ff-analyst/

You should see adapter_model.safetensors and adapter_config.json listed.

  • Now destroy the Vast.ai instance to stop all billing by running:
vastai destroy instance [[INSTANCE_ID="your instance ID"]]
  • Confirm the instance is gone by running:
vastai show instances

This should return an empty list. No instances means no charges accumulating.

Scp failing or instance not found?

  • If scp times out, the instance may have been stopped or destroyed already. Run vastai show instances to check if your instance is still running.
  • If you get a "permission denied" error, confirm you are using the same SSH key pair you configured in your Vast.ai account settings.
  • If vastai destroy instance returns an error about the instance not existing, it may already be destroyed. Run vastai show instances to confirm the list is empty.

Help me download my adapter from Vast.ai

Your fine-tuned fantasy football analyst is now safely on your local machine, and you are no longer paying for GPU time. You proved that 10 targeted examples can teach a model to commit to structured, actionable analysis instead of hedging.

Secret mission

Test Generalization with Novel News

Your model trained on 10 specific news patterns. Can it handle situations it has never seen before? In this mission, you'll design novel fantasy football scenarios, run them through your fine-tuned model, and evaluate whether it learned transferable reasoning or just memorized your examples.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project used a Vast.ai GPU instance billed per hour, but if you destroyed it in Step 4 (or the Secret Mission), no cloud charges are accumulating.

Cost warning

If you still have a Vast.ai instance running, it is billing at $0.30-0.50/hr. Run vastai show instances to check. If anything is listed, destroy it immediately:

vastai destroy instance [[INSTANCE_ID="your instance ID"]]

Resources you used:

  • Vast.ai GPU instance (billed per hour while running, should already be destroyed).
  • Local LoRA adapter in outputs/qwen3-8b-ff-analyst/ (~100MB).
  • Python scripts: test_base_model.py, train.py, test_finetuned.py.

Keep everything running

No action needed. Choose this if you plan to use the adapter in another inference setup or want to reference your training scripts later.

  • Your adapter is local and ready to load onto any compatible inference setup.
  • No cloud charges are accumulating if you already destroyed the Vast.ai instance.
  • Confirm no lingering instances by running:
vastai show instances

You should see an empty list.

Pause - I'll come back to this later

Free up disk space by removing the adapter directory while keeping your scripts for later reuse.

  • Remove the adapter directory (~100MB) by running:
rm -rf outputs/
  • Keep test_base_model.py, train.py, and test_finetuned.py for when you want to retrain with more examples.
  • Confirm no Vast.ai instances remain by running:
vastai show instances

You should see an empty list.

Delete - I don't want to use this again

Remove all project files and confirm no cloud resources remain.

  • Delete all local project files by running:
rm -rf outputs/ test_base_model.py train.py test_finetuned.py
  • Confirm no Vast.ai instances are still running by running:
vastai show instances

You should see an empty list. If any instance appears, destroy it immediately:

vastai destroy instance [[INSTANCE_ID="your instance ID"]]

Nice Work!

Nice Work!

You just fine-tuned a general-purpose LLM into an opinionated fantasy football analyst using QLoRA on a cloud GPU. That is real machine learning engineering.

You've learned how to:

  • Rent a cloud GPU from Vast.ai and manage the full lifecycle from search to destroy, keeping costs under control by tearing down instances the moment training completes.
  • Write supervised training examples that encode domain expertise and train a QLoRA adapter on Qwen3-8B, producing a portable ~100MB adapter that transforms generic model output into structured, actionable JSON recommendations.
  • Run a controlled before-and-after comparison proving the behavioral change. The base model hedges and rambles. Your fine-tuned model commits to a specific action with clear reasoning.
  • Secret Mission: Tested your model's generalization with novel news patterns it never saw in training and evaluated whether it learned transferable fantasy reasoning or just memorized examples.

Ready to quiz yourself?