Przejdź do treści głównej

Dostrajanie NVIDIA Nemotron-3-Nano na danych Q&A z psychologii

Dowiedz się, jak dostroić NVIDIA Nemotron-3-Nano-4B na zbiorze pytań i odpowiedzi z psychologii na GPU RTX 3090, używając LoRA i TRL po pobraniu modelu z Hugging Face.
Zaktualizowano 29 kwi 2026  · 6 min Czytać

NVIDIA Nemotron-3 to nowa, otwarta rodzina modeli NVIDIA stworzona z myślą o wnioskowaniu, kodowaniu, czacie i agentowych przepływach pracy AI. Obejmuje różne rozmiary modeli, takie jak Nano, Super i Ultra, dzięki czemu deweloperzy mogą wybierać między mniejszymi, wydajnymi modelami a większymi, wysoko wydajnymi.

Kluczową zmianą w Nemotron-3 jest nacisk na efektywność. Modele są zaprojektowane tak, aby zapewniać wysoką wydajność przy jednoczesnym utrzymaniu praktyczności wnioskowania i dostrajania. Wersja Nano jest szczególnie użyteczna do praktycznych eksperymentów, ponieważ może działać na bardziej dostępnych konfiguracjach GPU w porównaniu z większymi modelami.

W tym przewodniku dostroimy NVIDIA Nemotron-3-Nano-4B na zbiorze pytań i odpowiedzi z psychologii. Użyjemy Low-Rank Adaptation (LoRA), Transformers Reinforcement Learning (TRL) oraz Hugging Face do przygotowania danych, trenowania modelu, zapisania adaptera, wypchnięcia go do Hugging Face i porównania odpowiedzi przed i po dostrojeniu.

Aby zacząć znajdować najnowsze otwarte modele AI, budować agentów i dostrajać LLM-y, polecam zapisać się na naszą ścieżkę umiejętności Hugging Face Fundamentals.

1. Konfiguracja środowiska

Nemotron-3 Nano używa architektury hybrydowej, więc pakiety związane z Mambą muszą być poprawnie zainstalowane. W notatniku Jupyter najpierw usuwamy istniejący stos Pytorch i ponownie instalujemy kompilację PyTorch 2.7.1 dla CUDA 12.8, która działa z przypiętymi wersjami mamba_ssm i causal_conv1d użytymi tutaj.

Instalujemy też podstawowe biblioteki do dostrajania, w tym transformers, trl, accelerate, datasets, peft oraz 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"

Po zainstalowaniu pakietów sprawdź, czy CUDA jest dostępna i czy PyTorch wykrywa GPU. Ten notatnik jest dostrojony do GPU 24 GB, więc ostrzeże, jeśli Pana/Pani GPU ma mniej pamięci VRAM.

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

Wynik:

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.

Ustaw token Hugging Face jako zmienną środowiskową o nazwie HF_TOKEN. Dzięki temu notatnik pobierze model Nemotron-3, a następnie wypchnie dostrojony adapter LoRA do Hugging Face.

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. Wczytanie i przetwarzanie zbioru danych

Następnie wczytamy zbiór pytań i odpowiedzi z psychologii z Hugging Face. Zbiór zawiera kolumnę question oraz dwie kolumny odpowiedzi: response_j i response_k. W tym przewodniku użyjemy response_j jako docelowej odpowiedzi do nadzorowanego dostrajania.

Najpierw wczytujemy zbiór, losowo go mieszamy dla odtwarzalności i tworzymy podziały na zbiory treningowy, walidacyjny i testowy. 

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

Wynik:

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
    })
})

Przed sformatowaniem zbioru do treningu, sprawdź nazwy kolumn i obejrzyj jeden przykład. Potwierdza to, że zbiór został poprawnie wczytany i zawiera oczekiwane pola pytania i odpowiedzi.

dataset["train"].column_names, dataset["train"][0]

Wynik:

(
    ['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. Formatowanie zbioru danych do dostrajania w TRL

Teraz przekształcimy zbiór do formatu prompt–completion oczekiwanego przez TRL. Każdy przykład będzie zawierał prompt systemowy, pytanie użytkownika z psychologii oraz docelową odpowiedź asystenta z response_j.

Prompt systemowy instruuje model, jak odpowiadać: być wspierającym, unikać ukrytych śladów rozumowania, podawać praktyczne wskazówki i nie zachowywać się jak licencjonowany specjalista zdrowia psychicznego.

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]

Wynik:

{
   '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. Wczytanie bazowego modelu Nemotron-3

Następnie wczytamy tokenizer i bazowy model NVIDIA Nemotron-3 Nano 4B BF16 z Hugging Face. Ustawimy też katalog wyjściowy dla adaptera LoRA i ograniczymy długość sekwencji do 1024 tokenów, aby trening był wykonalny na GPU 24 GB.

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. Tworzenie pomocniczych funkcji generowania

Przed dostrajaniem stworzymy kilka funkcji pomocniczych do testowania odpowiedzi modelu. Funkcje te budują prompt czatu, generują odpowiedź, usuwają niechciane tagi myślenia i zapisują wyniki w małej tabeli porównawczej.

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. Wstępna ewaluacja próbek przed dostrajaniem

Przed treningiem wygenerujemy kilka odpowiedzi z bazowego modelu Nemotron-3. Da nam to punkt odniesienia, aby później porównać reakcje modelu przed i po dostrojeniu LoRA.

Tutaj wybieramy trzy przykłady z zestawu testowego i generujemy odpowiedzi, używając wcześniej utworzonej funkcji pomocniczej.

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

Wynik to mała tabela z oryginalnym pytaniem, referencyjną odpowiedzią z response_j oraz odpowiedzią wygenerowaną przez model bazowy. Ta tabela przyda się później, gdy porównamy ją z odpowiedziami modelu po dostrojeniu.

Wstępna ewaluacja próbek przed dostrajaniem

7. Konfiguracja LoRA i ustawień treningu

Teraz przygotujemy model do dostrajania LoRA. Włączamy checkpointing gradientów, aby zmniejszyć użycie pamięci, a następnie tworzymy konfigurację LoRA, która obejmie wszystkie warstwy liniowe w modelu.

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",
)

Następnie definiujemy ustawienia nadzorowanego dostrajania, używając SFTConfig. Parametry te kontrolują rozmiar partii, współczynnik uczenia, liczbę epok, częstotliwość ewaluacji, strategię zapisu oraz trening w BF16.

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. Trening i zapis adaptera LoRA

Możemy teraz utworzyć SFTTrainer, dołączyć konfigurację LoRA i rozpocząć dostrajanie. Przed treningiem sprawdzamy też, ile parametrów jest trenowalnych, aby potwierdzić, że adapter LoRA został poprawnie dołączony.

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

Podczas treningu strata treningowa i walidacyjna powinna stopniowo maleć. Zwykle oznacza to, że model uczy się stylu odpowiedzi ze zbioru danych.

Wyniki dostrajania

Po treningu zapisz lokalnie adapter LoRA i tokenizer:

trainer.model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)

Następnie wypchnij dostrojony adapter do 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)

Dostrojony adapter jest teraz zapisany lokalnie i przesłany do Hugging Face pod HUB_REPO_ID.

Wypchnięto dostrojony model do Hugging Face: kingabzpro/nemotron-3-nano-4b-bf16-psychology-qa-lora

Źródło: kingabzpro/nemotron-3-nano-4b-bf16-psychology-qa-lora

9. Porównanie odpowiedzi przed i po dostrajaniu

Na koniec wygenerujemy odpowiedzi z dostrojonego modelu i porównamy je z wynikami modelu bazowego. Pomoże nam to ocenić, czy dostrajanie LoRA poprawiło zbieżność modelu z odpowiedziami referencyjnymi.

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")

Porównanie odpowiedzi przed i po dostrajaniu

Dostrojony model stał się bardziej zbieżny ze stylem odpowiedzi referencyjnych. Był bardziej zwięzły i bliższy odpowiedziom ze zbioru. Jednak model bazowy czasem udzielał bardziej szczegółowych i praktycznych odpowiedzi.

Na przykład model po dostrojeniu lepiej dopasował się w pytaniach o zarządzanie stresem i koncentrację, ale w przykładzie dotyczącym snu model bazowy wypadał lepiej, bo zawierał więcej pomocnych szczegółów.

Ogólnie, dostrojony model jest lepszy, jeśli celem jest dopasowanie do stylu zbioru referencyjnego. Jeśli celem jest maksymalna pomocność, model bazowy może w niektórych przypadkach wypaść lepiej, ponieważ potrafi udzielać cieplejszych i bardziej rozbudowanych odpowiedzi.

Jeśli napotykają Państwo problemy z uruchomieniem powyższego kodu, proszę zajrzeć do notatnika w repozytorium Hugging Face: fine-tune-nemotron-nano.ipynb

Uwagi końcowe

Nawet po dostrojeniu 100+ LLM-ów, ten model wymagał więcej pracy konfiguracyjnej, niż się spodziewałem. Głównym wyzwaniem była zależność mamba_ssm, która łatwo może się wykrzaczyć lub wejść w konflikt z istniejącym lokalnym środowiskiem Pythona.

Z tego powodu polecam użyć czystego środowiska do tego przepływu pracy. W moim przypadku najłatwiejszą ścieżką było zbudowanie środowiska od nowa, zainstalowanie właściwej wersji PyTorch, przypięcie pakietów związanych z Mambą i uruchomienie notatnika z tego poziomu.

Innym ograniczeniem jest kwantyzacja. W tej konfiguracji nie mogłem po prostu załadować modelu w 4-bitach i dostrajać go jak w standardowym przepływie QLoRA, jak w moim tutorialu Qwen3.5 Small. Musiałem załadować pełny model BF16, a następnie dostrajać go z LoRA. Dla modelu 4B jest to nadal wykonalne na GPU 24 GB, ale dla modeli 12B i większych użycie pamięci szybko staje się problemem.

Niemniej dostrajanie na konsumenckich GPU stało się znacznie bardziej dostępne. Mając kartę 24 GB, taką jak RTX 3090, można dziś dostosować mocne otwarte modele do konkretnego stylu lub domeny bez potrzeby dużego klastra treningowego.

Podsumowując, Nemotron-3 Nano to sprawny model, ale wymaga starannej konfiguracji środowiska. Gdy zależności działają, dobrze się dostraja i potrafi zaadaptować do nowego stylu odpowiedzi przy stosunkowo niewielkiej liczbie przykładów.

Tematy

Ucz się AI z DataCamp!

Track

Associate AI Engineer for Developers

26 godz.
Learn how to integrate AI into software applications using APIs and open-source libraries. Start your journey to becoming an AI Engineer today!
Zobacz szczegółyRight Arrow
Rozpocznij kurs
Zobacz więcejRight Arrow