Cursus
NVIDIA Nemotron-3 est la nouvelle famille de modèles ouverts de NVIDIA, conçue pour le raisonnement, le code, le chat et les workflows d’agents d’IA. Elle comprend différentes tailles de modèles, comme Nano, Super et Ultra, afin que les développeurs puissent choisir entre des modèles plus petits et efficaces et des modèles plus grands et performants.
La principale évolution avec Nemotron-3 est l’accent mis sur l’efficacité. Les modèles sont conçus pour offrir de bonnes performances tout en rendant l’inférence et l’affinage plus pratiques. La version Nano est particulièrement utile pour l’expérimentation, car elle peut tourner sur des configurations GPU plus accessibles que les modèles plus volumineux.
Dans ce guide, nous allons affiner NVIDIA Nemotron-3-Nano-4B sur un jeu de données de questions-réponses en psychologie. Nous utiliserons Low-Rank Adaptation (LoRA), Transformers Reinforcement Learning (TRL) et Hugging Face pour préparer les données, entraîner le modèle, enregistrer l’adaptateur, le publier sur Hugging Face, et comparer les réponses avant et après l’affinage.
Pour découvrir les derniers modèles d’IA open source, créer des agents d’IA et affiner des LLM, nous vous recommandons de suivre notre parcours de compétences Hugging Face Fundamentals.
1. Préparer l’environnement
Nemotron-3 Nano utilise une architecture hybride, il faut donc installer correctement les packages liés à Mamba. Dans un notebook Jupyter, nous supprimons d’abord la pile PyTorch existante puis réinstallons la version CUDA 12.8 de PyTorch 2.7.1, compatible avec les versions épinglées de mamba_ssm et de causal_conv1d utilisées ici.
Nous installons aussi les bibliothèques essentielles pour l’affinage, notamment transformers, trl, accelerate, datasets, peft et 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"
Après l’installation, vérifiez que CUDA est disponible et que PyTorch détecte votre GPU. Ce notebook est optimisé pour un GPU de 24 Go, il vous avertira si votre GPU a moins de 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
Sortie :
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.
Définissez votre jeton Hugging Face comme variable d’environnement nommée HF_TOKEN. Cela permet de télécharger le modèle Nemotron-3 et, plus tard, de publier l’adaptateur LoRA affiné sur 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. Chargement et traitement du jeu de données
Nous allons maintenant charger, depuis Hugging Face, le jeu de données de questions-réponses en psychologie. Il contient une colonne question et deux colonnes de réponse : response_j et response_k. Dans ce guide, nous utiliserons response_j comme réponse cible pour l’affinage supervisé.
Nous chargeons d’abord le jeu de données, le mélangeons pour la reproductibilité, puis créons des partitions train, validation et test.
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
Sortie :
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
})
})
Avant de formater le jeu de données pour l’entraînement, vérifiez les noms des colonnes et affichez un exemple. Cela confirme que le chargement s’est bien déroulé et que les champs question et réponse sont présents.
dataset["train"].column_names, dataset["train"][0]
Sortie :
(
['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. Mise en forme du jeu de données pour l’affinage avec TRL
Nous allons maintenant convertir le jeu de données vers le format prompt–complétion attendu par TRL. Chaque exemple inclura une instruction système, la question de l’utilisateur en psychologie, et la réponse cible de l’assistant issue de response_j.
L’instruction système précise la manière de répondre : être dans le soutien, éviter les traces de raisonnement cachées, donner des suggestions pratiques, et ne pas se présenter comme un professionnel de santé mentale diplômé.
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]
Sortie :
{
'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. Chargement du modèle de base Nemotron-3
Nous allons ensuite charger depuis Hugging Face le tokenizer et le modèle de base NVIDIA Nemotron-3 Nano 4B BF16. Nous définissons aussi le répertoire de sortie pour l’adaptateur LoRA et limitons la longueur de séquence à 1024 tokens pour conserver un entraînement gérable sur un GPU de 24 Go.
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. Créer des fonctions d’aide à la génération
Avant l’affinage, nous allons créer quelques fonctions utilitaires pour tester les réponses du modèle. Elles construisent l’invite de chat, génèrent une réponse, retirent d’éventuelles balises de "pensée" non souhaitées, et stockent les résultats dans un petit tableau de comparaison.
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. Évaluation d’échantillons avant affinage
Avant l’entraînement, nous allons générer quelques réponses à partir du modèle Nemotron-3 de base. Cela nous donne une référence pour comparer ensuite les réponses avant et après l’affinage LoRA.
Ici, nous sélectionnons trois exemples du jeu de test et générons des réponses à l’aide de la fonction utilitaire créée plus haut.
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
La sortie est un petit tableau avec la question originale, la réponse de référence de response_j et la réponse générée par le modèle de base. Ce tableau sera utile plus tard pour la comparaison avec les réponses du modèle affiné.

7. Configuration de LoRA et des paramètres d’entraînement
Nous allons maintenant préparer le modèle pour l’affinage LoRA. Nous activons le gradient checkpointing pour réduire l’empreinte mémoire, puis créons une configuration LoRA qui cible toutes les couches linéaires du modèle.
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",
)
Ensuite, nous définissons les paramètres d’affinage supervisé avec SFTConfig. Ces paramètres contrôlent la taille de lot, le taux d’apprentissage, le nombre d’époques, la fréquence d’évaluation, la stratégie de sauvegarde, ainsi que l’entraînement en 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. Entraîner et enregistrer l’adaptateur LoRA
Nous pouvons maintenant créer le SFTTrainer, attacher la configuration LoRA et lancer l’affinage. Avant d’entraîner, nous vérifions aussi le nombre de paramètres entraînables pour confirmer que l’adaptateur LoRA a bien été appliqué.
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
Pendant l’entraînement, la perte d’entraînement et la perte de validation devraient diminuer progressivement. Cela indique généralement que le modèle apprend le style de réponse du jeu de données.

Après l’entraînement, enregistrez localement l’adaptateur LoRA et le tokenizer :
trainer.model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
Puis publiez l’adaptateur affiné sur 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)
L’adaptateur affiné est maintenant enregistré localement et téléversé sur Hugging Face sous l’identifiant HUB_REPO_ID.

Source : kingabzpro/nemotron-3-nano-4b-bf16-psychology-qa-lora
9. Comparer les réponses avant et après l’affinage
Enfin, nous allons générer des réponses avec le modèle affiné et les comparer aux sorties du modèle de base. Cela permet de voir si l’affinage LoRA a amélioré l’alignement du modèle avec les réponses de référence.
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")

Le modèle affiné est davantage aligné sur le style de réponse de référence. Il est plus concis et reste plus proche des réponses du jeu de données. Cependant, le modèle de base fournit parfois des réponses plus détaillées et plus pratiques.
Par exemple, le modèle affiné a mieux respecté l’alignement sur les questions liées à la gestion du stress et à la concentration, mais le modèle de base a proposé une réponse plus solide pour l’exemple lié au sommeil, car elle comportait davantage de détails utiles.
Dans l’ensemble, le modèle affiné est préférable si votre objectif est de correspondre au style du jeu de données de référence. Si votre objectif est une aide maximale, le modèle de base peut encore mieux faire dans certains cas, car il peut fournir des réponses plus chaleureuses et plus détaillées.
Si vous rencontrez des difficultés pour exécuter le code ci-dessus, consultez le notebook dans le dépôt Hugging Face : fine-tune-nemotron-nano.ipynb
Conclusion
Même après avoir affiné plus de 100 LLM, ce modèle a nécessité plus de mise en place que prévu. Le principal défi a été la dépendance mamba_ssm, qui peut facilement se casser ou entrer en conflit avec un environnement Python local existant.
Pour cette raison, je recommande d’utiliser un environnement propre pour ce workflow. Dans mon cas, la voie la plus simple a été de reconstruire l’environnement, installer la bonne version de PyTorch, épingler les packages liés à Mamba, puis exécuter le notebook à partir de là.
Une autre limite concerne la quantification. Dans cette configuration, je n’ai pas pu simplement charger le modèle en 4 bits et l’affiner comme dans un workflow QLoRA standard, comme dans mon tutoriel Qwen3.5 Small. J’ai dû charger le modèle complet en BF16 puis l’affiner avec LoRA. Pour un modèle 4B, cela reste gérable sur un GPU de 24 Go, mais pour des modèles 12B et plus, l’utilisation mémoire peut vite devenir problématique.
Cela dit, l’affinage sur GPU grand public est devenu beaucoup plus accessible. Avec une carte 24 Go comme la RTX 3090, il est désormais possible d’adapter des modèles ouverts performants à un style ou un domaine spécifique sans avoir besoin d’un large cluster d’entraînement.
Globalement, Nemotron-3 Nano est un modèle capable, mais il nécessite une configuration d’environnement soignée. Une fois les dépendances en place, il s’affine bien et peut adopter un nouveau style de réponse avec un nombre d’exemples relativement limité.

En tant que data scientist certifié, je suis passionné par l'utilisation des technologies de pointe pour créer des applications innovantes d'apprentissage automatique. Avec une solide expérience en reconnaissance vocale, en analyse de données et en reporting, en MLOps, en IA conversationnelle et en NLP, j'ai affiné mes compétences dans le développement de systèmes intelligents qui peuvent avoir un impact réel. En plus de mon expertise technique, je suis également un communicateur compétent, doué pour distiller des concepts complexes dans un langage clair et concis. En conséquence, je suis devenu un blogueur recherché dans le domaine de la science des données, partageant mes idées et mes expériences avec une communauté grandissante de professionnels des données. Actuellement, je me concentre sur la création et l'édition de contenu, en travaillant avec de grands modèles linguistiques pour développer un contenu puissant et attrayant qui peut aider les entreprises et les particuliers à tirer le meilleur parti de leurs données.