Lernpfad
NVIDIA Nemotron-3 ist NVIDIAs neue offene Modellfamilie für Reasoning, Coding, Chat und agentische KI-Workflows. Sie umfasst verschiedene Modellgrößen wie Nano, Super und Ultra, sodass Entwickler zwischen kleineren, effizienten und größeren, leistungsstarken Modellen wählen können.
Die wichtigste Neuerung bei Nemotron-3 ist der Fokus auf Effizienz. Die Modelle liefern starke Performance und halten gleichzeitig Inferenz und Feintuning praktikabel. Die Nano-Version eignet sich besonders gut für praktische Experimente, da sie auf zugänglicherer GPU-Hardware läuft als größere Modelle.
In dieser Anleitung feintunen wir NVIDIA Nemotron-3-Nano-4B auf einem Psychologie-Frage-Antwort-Datensatz. Wir nutzen Low-Rank Adaptation (LoRA), Transformers Reinforcement Learning (TRL) und Hugging Face, um die Daten vorzubereiten, das Modell zu trainieren, den Adapter zu speichern, zu Hugging Face zu pushen und die Antworten vor und nach dem Feintuning zu vergleichen.
Wenn du die neuesten Open-Source-KI-Modelle finden, KI-Agenten bauen und LLMs feintunen willst, empfehle ich dir unseren Hugging Face Fundamentals-Lernpfad.
1. Einrichtung der Umgebung
Nemotron-3 Nano nutzt eine Hybridarchitektur, daher müssen die Mamba-bezogenen Pakete korrekt installiert werden. In einem Jupyter-Notebook entfernen wir zunächst den vorhandenen PyTorch-Stack und installieren die CUDA-12.8-Version von PyTorch 2.7.1 neu, die mit den hier fixierten Versionen von mamba_ssm und causal_conv1d funktioniert.
Außerdem installieren wir die zentralen Feintuning-Bibliotheken, darunter transformers, trl, accelerate, datasets, peft und huggingface_hub.
%%capture
!pip install -U packaging ninja
# Replace the current PyTorch stack with the CUDA 12.8 build that works with these Mamba kernel pins.
!pip uninstall -y torch torchvision torchaudio triton
!pip install "torch==2.7.1" "torchvision==0.22.1" "torchaudio==2.7.1" --index-url https://download.pytorch.org/whl/cu128
!pip install -U "transformers==4.56.2" tokenizers "trl==0.22.2" accelerate datasets peft pandas tqdm huggingface_hub safetensors
!pip install -U --no-build-isolation "mamba_ssm==2.2.5" "causal_conv1d==1.5.2"
Nach der Installation der Pakete prüfst du, ob CUDA verfügbar ist und PyTorch deine GPU erkennt. Dieses Notebook ist auf eine 24-GB-GPU abgestimmt und warnt dich, wenn deine GPU weniger VRAM hat.
import os
import platform
import torch
print(f"Python: {platform.python_version()}")
print(f"PyTorch: {torch.__version__}")
print(f"PyTorch CUDA build: {torch.version.cuda}")
print(f"CUDA available: {torch.cuda.is_available()}")
if not torch.cuda.is_available():
raise RuntimeError(
"CUDA is not available. Select a RunPod PyTorch image with GPU support."
)
for idx in range(torch.cuda.device_count()):
props = torch.cuda.get_device_properties(idx)
total_gb = props.total_memory / 1024**3
print(
f"GPU {idx}: {props.name} ({total_gb:.1f} GB VRAM, capability {props.major}.{props.minor})"
)
if torch.cuda.get_device_properties(0).total_memory < 24 * 1024**3:
print(
"Warning: this 4B LoRA notebook is tuned for GPUs with at least 24GB VRAM. Reduce batch sizes on smaller GPUs."
)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
Ausgabe:
Python: 3.12.3
PyTorch: 2.7.1+cu128
PyTorch CUDA build: 12.8
CUDA available: True
GPU 0: NVIDIA GeForce RTX 3090 (23.6 GB VRAM, capability 8.6)
Warning: this 4B LoRA notebook is tuned for GPUs with at least 24GB VRAM. Reduce batch sizes on smaller GPUs.
Setze deinen Hugging-Face-Token als Umgebungsvariable mit dem Namen HF_TOKEN. So kann das Notebook das Nemotron-3-Modell herunterladen und später den feingetunten LoRA-Adapter zu Hugging Face pushen.
from huggingface_hub import login
hf_token = os.environ.get("HF_TOKEN")
if not hf_token:
raise ValueError(
"Set HF_TOKEN in the RunPod environment before running this notebook."
)
login(token=hf_token)
print("Logged in to Hugging Face.")
2. Laden und Verarbeiten des Datensatzes
Als Nächstes laden wir den Psychologie-Q&A-Datensatz von Hugging Face. Der Datensatz enthält eine Spalte question und zwei Antwortspalten: response_j und response_k. Für diese Anleitung verwenden wir response_j als Zielantwort für das überwachte Feintuning.
Wir laden den Datensatz, mischen ihn für Reproduzierbarkeit und erstellen Trainings-, Validierungs- und Test-Splits.
from datasets import DatasetDict, load_dataset
DATASET_ID = "jkhedri/psychology-dataset"
TRAIN_LIMIT = 8000
VALIDATION_LIMIT = 800
TEST_LIMIT = 300
SEED = 42
raw_dataset = load_dataset(DATASET_ID)
raw_train = raw_dataset["train"].shuffle(seed=SEED)
split_1 = raw_train.train_test_split(test_size=0.15, seed=SEED)
split_2 = split_1["test"].train_test_split(test_size=0.33, seed=SEED)
def maybe_limit(split, limit):
if limit is None:
return split
return split.select(range(min(limit, len(split))))
dataset = DatasetDict(
{
"train": maybe_limit(split_1["train"], TRAIN_LIMIT),
"validation": maybe_limit(split_2["train"], VALIDATION_LIMIT),
"test": maybe_limit(split_2["test"], TEST_LIMIT),
}
)
dataset
Ausgabe:
DatasetDict({
train: Dataset({
features: ['question', 'response_j', 'response_k'],
num_rows: 8000
})
validation: Dataset({
features: ['question', 'response_j', 'response_k'],
num_rows: 800
})
test: Dataset({
features: ['question', 'response_j', 'response_k'],
num_rows: 300
})
})
Bevor wir den Datensatz fürs Training formatieren, prüfen wir die Spaltennamen und schauen uns ein Beispiel an. So stellst du sicher, dass der Datensatz korrekt geladen wurde und die erwarteten Felder für Frage und Antwort enthält.
dataset["train"].column_names, dataset["train"][0]
Ausgabe:
(
['question', 'response_j', 'response_k'],
{
'question': "I'm experiencing anxiety about social situations and don't know how to cope.",
'response_j': "Social anxiety can be a difficult and isolating experience, but there are effective treatments available. Let's work on developing coping mechanisms, such as deep breathing and mindfulness, and exposure therapy to gradually confront your fears. We can also explore ways to improve social skills and build self-confidence.",
'response_k': "Just avoid social situations. It's not worth the anxiety and discomfort. You can also try using alcohol or drugs to help you feel more comfortable in social settings."
}
)
3. Formatierung des Datensatzes für TRL-Feintuning
Nun wandeln wir den Datensatz in das von TRL erwartete Prompt-Completion-Format um. Jedes Beispiel enthält einen System-Prompt, die Psychologiefrage des Users und die Zielantwort der Assistenz aus response_j.
Der System-Prompt gibt vor, wie geantwortet werden soll: unterstützend, ohne versteckte Reasoning-Spuren, mit praktischen Vorschlägen und ohne sich als approbierte Fachkraft auszugeben.
SYSTEM_PROMPT = """/no_think
You are a supportive psychology question-answering assistant.
Do not include hidden reasoning, thinking traces, <think> tags, or </think> tags in the final answer.
Respond with empathy, practical coping suggestions, and clear next steps.
Give a complete answer in 2-4 short paragraphs or a brief paragraph plus 3-5 practical bullets.
Do not diagnose the user or claim to replace a licensed mental health professional.
If the user may be in immediate danger or crisis, encourage contacting local emergency services or a trusted crisis hotline.
Keep the answer safe, specific, and directly relevant to the user's question without being overly brief."""
CHAT_TEMPLATE_KWARGS = {"enable_thinking": False}
USER_TEMPLATE = "Question:\n\n{question}"
def clean_text(value):
return " ".join(str(value).strip().split())
def to_prompt_completion(example):
question = clean_text(example["question"])
answer = clean_text(example["response_j"])
return {
"prompt": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_TEMPLATE.format(question=question)},
],
"completion": [
{"role": "assistant", "content": answer},
],
"chat_template_kwargs": CHAT_TEMPLATE_KWARGS,
}
sft_dataset = dataset.map(
to_prompt_completion, remove_columns=dataset["train"].column_names
)
sft_dataset["train"][0]
Ausgabe:
{
'prompt': [
{
'role': 'system',
'content': "/no_think\nYou are a supportive psychology question-answering assistant.\nDo not include hidden reasoning, thinking traces, <think> tags, or </think> tags in the final answer.\nRespond with empathy, practical coping suggestions, and clear next steps.\nGive a complete answer in 2-4 short paragraphs or a brief paragraph plus 3-5 practical bullets.\nDo not diagnose the user or claim to replace a licensed mental health professional.\nIf the user may be in immediate danger or crisis, encourage contacting local emergency services or a trusted crisis hotline.\nKeep the answer safe, specific, and directly relevant to the user's question without being overly brief."
},
{
'role': 'user',
'content': "Question:\n\nI'm experiencing anxiety about social situations and don't know how to cope."
}
],
'completion': [
{
'role': 'assistant',
'content': "Social anxiety can be a difficult and isolating experience, but there are effective treatments available. Let's work on developing coping mechanisms, such as deep breathing and mindfulness, and exposure therapy to gradually confront your fears. We can also explore ways to improve social skills and build self-confidence."
}
],
'chat_template_kwargs': {'enable_thinking': False}
}
4. Laden des Nemotron-3-Basismodells
Als Nächstes laden wir den Tokenizer und das Basismodell NVIDIA Nemotron-3 Nano 4B BF16 von Hugging Face. Außerdem setzen wir das Ausgabeverzeichnis für den LoRA-Adapter und begrenzen die Sequenzlänge auf 1024 Tokens, um das Training auf einer 24-GB-GPU handhabbar zu halten.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
OUTPUT_DIR = "./nemotron-3-nano-4b-bf16-psychology-qa-lora"
MAX_SEQ_LENGTH = 1024
tokenizer = AutoTokenizer.from_pretrained(
MODEL_ID,
token=hf_token,
trust_remote_code=True,
use_fast=True,
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
base_model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
token=hf_token,
trust_remote_code=True,
dtype=torch.bfloat16,
device_map="auto",
attn_implementation="eager",
)
base_model.config.use_cache = False
base_model.config.pad_token_id = tokenizer.pad_token_id
base_model.config.eos_token_id = tokenizer.eos_token_id
base_model.generation_config.pad_token_id = tokenizer.pad_token_id
base_model.generation_config.eos_token_id = tokenizer.eos_token_id
base_model.generation_config.use_cache = False
base_model.generation_config.do_sample = False
base_model.generation_config.top_p = None
base_model.generation_config.min_new_tokens = None
base_model.generation_config.repetition_penalty = 1.08
base_model.generation_config.no_repeat_ngram_size = 4
5. Hilfsfunktionen für die Generierung erstellen
Bevor wir feintunen, erstellen wir ein paar Helferfunktionen, um die Antworten des Modells zu testen. Diese Funktionen bauen den Chat-Prompt, generieren eine Antwort, entfernen unerwünschte Thinking-Tags und speichern die Ergebnisse in einer kleinen Vergleichstabelle.
import gc
import pandas as pd
from tqdm.auto import tqdm
def clear_cuda_cache():
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def build_messages(question, system_prompt=SYSTEM_PROMPT):
return [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": USER_TEMPLATE.format(question=clean_text(question)),
},
]
def remove_thinking_text(text):
text = text.strip()
while "<think>" in text and "</think>" in text:
start = text.find("<think>")
end = text.find("</think>", start) + len("</think>")
text = (text[:start] + text[end:]).strip()
if "</think>" in text:
text = text.split("</think>")[-1].strip()
return text.replace("<think>", "").replace("</think>", "").strip()
def generate_answer(
model, tokenizer, question, system_prompt=SYSTEM_PROMPT, max_new_tokens=180
):
messages = build_messages(question, system_prompt)
device = next(model.parameters()).device
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
**CHAT_TEMPLATE_KWARGS,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
)
inputs = {key: value.to(device) for key, value in inputs.items()}
input_len = inputs["input_ids"].shape[-1]
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
use_cache=False,
repetition_penalty=1.08,
no_repeat_ngram_size=4,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=tokenizer.eos_token_id,
)
decoded = tokenizer.decode(outputs[0][input_len:], skip_special_tokens=True).strip()
return remove_thinking_text(decoded)
def generate_sample_table(model, tokenizer, examples, output_column):
rows = []
model.eval()
for ex in tqdm(examples, desc=f"Generating {output_column}", leave=False):
rows.append(
{
"question": clean_text(ex["question"]),
"reference_response_j": clean_text(ex["response_j"]),
output_column: generate_answer(model, tokenizer, ex["question"]),
}
)
return pd.DataFrame(rows)
6. Beispielauswertung vor dem Feintuning
Vor dem Training generieren wir ein paar Antworten mit dem Nemotron-3-Basismodell. Das dient als Basislinie, um später zu vergleichen, wie sich die Antworten vor und nach dem LoRA-Feintuning unterscheiden.
Hier wählen wir drei Beispiele aus dem Testsplit und erzeugen Antworten mit der zuvor erstellten Hilfsfunktion.
sample_examples = [dataset["test"][idx] for idx in range(min(3, len(dataset["test"])))]
pre_samples = generate_sample_table(
base_model,
tokenizer,
sample_examples,
"base_model_answer"
)
pre_samples
Die Ausgabe ist eine kleine Tabelle mit der Originalfrage, der Referenzantwort aus response_j und der vom Basismodell generierten Antwort. Diese Tabelle hilft uns später beim Vergleich mit den Antworten des feingetunten Modells.

7. LoRA und Training konfigurieren
Jetzt bereiten wir das Modell für LoRA-Feintuning vor. Wir aktivieren Gradient Checkpointing, um den Speicherbedarf zu senken, und erstellen dann eine LoRA-Konfiguration, die alle linearen Schichten des Modells adressiert.
from peft import LoraConfig
base_model.gradient_checkpointing_enable()
base_model.config.use_cache = False
lora_config = LoraConfig(
r=32,
lora_alpha=64,
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM",
target_modules="all-linear",
)
Als Nächstes definieren wir die Einstellungen fürs überwachte Feintuning mit SFTConfig. Diese Parameter steuern Batchgröße, Lernrate, Epochenzahl, Auswertungsfrequenz, Speicherstrategie und BF16-Training.
from trl import SFTConfig, SFTTrainer
training_args = SFTConfig(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=8,
per_device_eval_batch_size=8,
gradient_accumulation_steps=8,
learning_rate=5e-5,
weight_decay=0.01,
lr_scheduler_type="linear",
warmup_ratio=0.05,
num_train_epochs=2,
logging_steps=50,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=100,
save_total_limit=2,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
gradient_checkpointing=True,
bf16=True,
fp16=False,
tf32=True,
max_length=MAX_SEQ_LENGTH,
packing=False,
completion_only_loss=True,
remove_unused_columns=False,
dataloader_num_workers=4,
optim="adamw_torch_fused",
report_to="none",
seed=SEED,
)
8. Training und Speichern des LoRA-Adapters
Jetzt erstellen wir den SFTTrainer, hängen die LoRA-Konfiguration an und starten das Feintuning. Vor dem Training prüfen wir, wie viele Parameter trainierbar sind, um sicherzustellen, dass der LoRA-Adapter korrekt angehängt wurde.
trainer = SFTTrainer(
model=base_model,
args=training_args,
train_dataset=sft_dataset["train"],
eval_dataset=sft_dataset["validation"],
peft_config=lora_config,
processing_class=tokenizer,
)
trainable_params = sum(
param.numel() for param in trainer.model.parameters() if param.requires_grad
)
all_params = sum(param.numel() for param in trainer.model.parameters())
if trainable_params == 0:
raise RuntimeError(
"No trainable LoRA parameters were attached. Check target_modules before training."
)
print(f"Trainable LoRA parameters: {trainable_params:,}")
print(f"All parameters visible to trainer: {all_params:,}")
print(f"Trainable percentage: {100 * trainable_params / all_params:.4f}%")
train_result = trainer.train()
trainer.model.eval()
trainer.model.config.use_cache = False
trainer.model.generation_config.use_cache = False
train_result
Während des Trainings sollten sich Trainings- und Validierungsverlust schrittweise verringern. Das zeigt in der Regel, dass das Modell den Antwortstil aus dem Datensatz lernt.

Nach dem Training speicherst du den LoRA-Adapter und den Tokenizer lokal:
trainer.model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
Anschließend pushst du den feingetunten Adapter zu Hugging Face:
HUB_REPO_ID = "kingabzpro/nemotron-3-nano-4b-bf16-psychology-qa-lora"
trainer.model.push_to_hub(HUB_REPO_ID, private=False)
tokenizer.push_to_hub(HUB_REPO_ID, private=False)
Der feingetunte Adapter ist nun lokal gespeichert und unter HUB_REPO_ID zu Hugging Face hochgeladen.

Quelle: kingabzpro/nemotron-3-nano-4b-bf16-psychology-qa-lora
9. Antworten vor und nach dem Feintuning vergleichen
Zum Schluss generieren wir Antworten mit dem feingetunten Modell und vergleichen sie mit den Ausgaben des Basismodells. So sehen wir, ob das LoRA-Feintuning die Angleichung an die Referenzantworten verbessert hat.
post_samples = generate_sample_table(
trainer.model,
tokenizer,
sample_examples,
"fine_tuned_answer"
)
comparison = pre_samples[
["question", "reference_response_j", "base_model_answer"]
].merge(
post_samples[["question", "fine_tuned_answer"]],
on="question",
how="left",
)
for idx, row in comparison.iterrows():
print("=" * 100)
print(f"Sample {idx + 1}")
print("=" * 100)
print("\nQUESTION:\n")
print(row["question"])
print("\nREFERENCE RESPONSE_J:\n")
print(row["reference_response_j"])
print("\nBASE MODEL ANSWER:\n")
print(row["base_model_answer"])
print("\nFINE-TUNED ANSWER:\n")
print(row["fine_tuned_answer"])
print("\n")

Das feingetunte Modell orientierte sich stärker am Stil der Referenzantworten. Es war prägnanter und blieb näher an den Datensätzen. Allerdings gab das Basismodell teils ausführlichere und praktischere Antworten.
Beispielsweise verbesserte das feingetunte Modell die Abstimmung bei Fragen zu Stressbewältigung und Konzentration, während das Basismodell beim Schlaf-Beispiel vorne lag, weil es hilfreichere Details enthielt.
Insgesamt ist das feingetunte Modell besser, wenn du dich am Stil des Referenzdatensatzes orientieren willst. Geht es dir um maximale Hilfsbereitschaft, kann das Basismodell in manchen Fällen überlegen sein, weil es wärmer und ausführlicher antwortet.
Wenn du Probleme beim Ausführen des Codes hast, schau ins Notebook im Hugging-Face-Repo: fine-tune-nemotron-nano.ipynb
Abschlussgedanken
Selbst nach dem Feintuning von über 100 LLMs brauchte dieses Modell mehr Setup als erwartet. Die größte Hürde war die mamba_ssm-Abhängigkeit, die leicht bricht oder mit einer bestehenden lokalen Python-Umgebung kollidiert.
Darum empfehle ich für diesen Workflow eine saubere Umgebung. Für mich war der einfachste Weg, die Umgebung neu aufzusetzen, die passende PyTorch-Version zu installieren, die Mamba-bezogenen Pakete zu pinnen und das Notebook von dort aus zu starten.
Eine weitere Einschränkung ist die Quantisierung. In diesem Setup konnte ich das Modell nicht einfach in 4-Bit laden und wie in einem normalen QLoRA-Workflow feintunen, wie in meinem Qwen3.5 Small-Tutorial. Ich musste das vollständige BF16-Modell laden und dann mit LoRA feintunen. Für ein 4B-Modell ist das auf einer 24-GB-GPU noch gut machbar, aber bei 12B und größer wird der Speicher schnell knapp.
Trotzdem ist Feintuning auf Consumer-GPUs deutlich zugänglicher geworden. Mit einer 24-GB-Karte wie der RTX 3090 lässt sich heute ein starkes Open-Model auf einen spezifischen Stil oder ein bestimmtes Fachgebiet anpassen, ohne einen großen Trainingscluster zu benötigen.
Unterm Strich ist Nemotron-3 Nano ein leistungsfähiges Modell, benötigt aber eine sorgfältige Einrichtung der Umgebung. Wenn die Abhängigkeiten passen, lässt es sich gut feintunen und mit relativ wenigen Beispielen an einen neuen Antwortstil anpassen.

Als zertifizierter Data Scientist ist es meine Leidenschaft, modernste Technologien zu nutzen, um innovative Machine Learning-Anwendungen zu entwickeln. Mit meinem fundierten Hintergrund in den Bereichen Spracherkennung, Datenanalyse und Reporting, MLOps, KI und NLP habe ich meine Fähigkeiten bei der Entwicklung intelligenter Systeme verfeinert, die wirklich etwas bewirken können. Neben meinem technischen Fachwissen bin ich auch ein geschickter Kommunikator mit dem Talent, komplexe Konzepte in eine klare und prägnante Sprache zu fassen. Das hat dazu geführt, dass ich ein gefragter Blogger zum Thema Datenwissenschaft geworden bin und meine Erkenntnisse und Erfahrungen mit einer wachsenden Gemeinschaft von Datenexperten teile. Zurzeit konzentriere ich mich auf die Erstellung und Bearbeitung von Inhalten und arbeite mit großen Sprachmodellen, um aussagekräftige und ansprechende Inhalte zu entwickeln, die sowohl Unternehmen als auch Privatpersonen helfen, das Beste aus ihren Daten zu machen.