File size: 6,878 Bytes
553acf2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
---
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
<character card><|im_end|>
<|im_start|>user
<player message><|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 <Name>. Background: <who they are, personality, motives>
Current Location: <the scene> 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 <Name>`
  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