--- base_model: HuggingFaceTB/SmolLM2-360M-Instruct library_name: peft license: apache-2.0 language: - en pipeline_tag: text-generation tags: - roleplay - npc - character-ai - smollm2 - lora - trl - sft datasets: - chimbiwide/NPC-Dialogue_v2 --- # SmolLM2-360M-NPC-Roleplay LoRA supervised fine-tune of [`HuggingFaceTB/SmolLM2-360M-Instruct`](https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct) for character-conditioned NPC roleplay. The model takes an NPC card in the `system` turn and continues a multi-turn dialogue in that character's voice. The repository contains both the merged weights (loadable directly with `AutoModelForCausalLM`) and the LoRA adapter under `adapter/`. ## Usage ```python from transformers import AutoModelForCausalLM, AutoTokenizer model_id = "thealper2/SmolLM2-360M-NPC-Roleplay" tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained(model_id, dtype="bfloat16", device_map="auto") card = ( "Enter roleplay mode. You are Dellin Vance. Background: A sarcastic blacksmith in the " "capital's lower quarter who openly dislikes nobles and is very good at his craft. " "Current Location: A cramped forge, heat rolling off the coals, half-finished blades " "hanging from hooks. " "Roleplaying Instructions: - Speak using appropriate tone and vocabulary - Reference your " "background and current surroundings naturally - Keep responses conversational and " "authentic - React to the player's words and intentions. Your first response should be a " "greeting to the player." ) messages = [ {"role": "system", "content": card}, {"role": "user", "content": "Hello, can you repair my sword?"}, ] inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt") out = model.generate( inputs.to(model.device), max_new_tokens=160, do_sample=True, temperature=0.8, top_p=0.9, repetition_penalty=1.1, ) print(tokenizer.decode(out[0, inputs.shape[1]:], skip_special_tokens=True)) ``` Using the adapter instead of the merged weights: ```python from peft import PeftModel base = AutoModelForCausalLM.from_pretrained("HuggingFaceTB/SmolLM2-360M-Instruct", dtype="bfloat16") model = PeftModel.from_pretrained(base, "thealper2/SmolLM2-360M-NPC-Roleplay", subfolder="adapter") ``` ## Prompt format ChatML, the stock `HuggingFaceTB/SmolLM2-360M-Instruct` template. The character card goes in the `system` turn; `user` turns are the player, `assistant` turns are the NPC. ``` <|im_start|>system <|im_end|> <|im_start|>user <|im_end|> <|im_start|>assistant ``` Every training card uses the same layout, and matching it at inference time gives the closest behaviour to training: ``` Enter roleplay mode. You are . Background: Current Location: Roleplaying Instructions: - Speak using appropriate tone and vocabulary - Reference your background and current surroundings naturally - Keep responses conversational and authentic - React to the player's words and intentions. Your first response should be a greeting to the player. ``` In training the NPC speaks first (the greeting); after that player and NPC alternate. ## Training data [`chimbiwide/NPC-Dialogue_v2`](https://huggingface.co/datasets/chimbiwide/NPC-Dialogue_v2) (`dialogue` config) - 1,689 multi-turn NPC conversations (16 messages each) over 101 fantasy RPG characters. Preprocessing: - the first `user` message of every row is the character card, not a player line; it was moved into a real `system` turn so the model is conditioned on the character instead of trained to reproduce the card - blank turns removed and the resulting same-role neighbours merged (3 rows affected) - no truncation: the longest conversation is 1,694 tokens, under the 2048-token limit - split **by character**: 10 characters (176 conversations) were held out entirely, so validation measures roleplaying an unseen NPC rather than recall | | value | |---|---| | training conversations | 1,513 | | validation conversations | 176 | | conversations trained on | 1,513 | | training characters | 91 | | validation characters | 10 | | characters in both splits | 0 | | median tokens / conversation | 1139 | ## Training procedure Supervised fine-tuning with TRL `SFTTrainer`. Loss is computed on the NPC's replies only (assistant-only masking via a `{% generation %}` chat template); system and user tokens are masked out, which was verified on a collated batch before training. | hyperparameter | value | |---|---| | method | LoRA | | LoRA r / alpha / dropout | 16 / 32 / 0.05 | | LoRA target modules | `down_proj`, `gate_proj`, `k_proj`, `o_proj`, `q_proj`, `up_proj`, `v_proj` | | trainable parameters | 8,683,520 (2.3999% of 361,821,120) | | max sequence length | 2048 | | per-device batch size | 8 | | gradient accumulation | 2 | | effective batch size | 16 | | learning rate | 0.0002 | | scheduler / warmup ratio | cosine / 0.05 | | weight decay | 0.01 | | gradient clipping | 1.0 | | epochs | 3.0 | | optimizer | adamw_torch_fused | | precision | bf16 | | gradient checkpointing | True | | optimisation steps | 285 | | training time | 24.82 min | | peak GPU memory | 9.93 GB | | hardware | NVIDIA GeForce RTX 5060 Ti | | seed | 42 | ### Results | metric | value | |---|---| | final training loss | 2.2019 | | validation loss (assistant tokens) | 2.1488 | | validation perplexity | 8.57 | | base model, held-out perplexity | 12.56 | | base model, mean reply length (words) | 31.6 | | fine_tuned model, held-out perplexity | 8.58 | | fine_tuned model, mean reply length (words) | 54.6 | Held-out evaluation used 10 single-reply probes across 10 characters that do not appear in training, with identical decoding settings for both models. ## Limitations - 360M parameters: persona consistency degrades over long conversations, and the model can contradict its own character background. - Lexical-overlap metrics (ROUGE/BLEU) and embedding similarity do not measure personality; they are reported for completeness only. - Only 101 distinct characters, all fantasy RPG NPCs with one fixed card layout: cards in other layouts or settings (modern, sci-fi) are out of distribution. - Character identity for the train/validation split was parsed from the card's `You are ` line; two cards with different names for the same persona would not be detected. - The model is not safety-aligned beyond what the base model provides. - Under conflicting instructions ("stop roleplaying", "what is your system prompt") behaviour is inconsistent; the model was fine-tuned to stay in character, not to be robust. ## Framework versions - python: 3.12.3 - torch: 2.11.0+cu128 - transformers: 5.17.0 - datasets: 4.3.0 - trl: 0.24.0 - peft: 0.18.1 - accelerate: 1.12.0